Compare commits

..

5 Commits

Author SHA1 Message Date
Keith Edmunds
0f1d5117cc V3 tweaks 2023-11-27 22:44:20 +00:00
Keith Edmunds
4eabf4a02a WIP V3: ready for testing 2023-11-27 21:46:19 +00:00
Keith Edmunds
00d7258afd WIP V3: OBS scene changes working 2023-11-27 21:27:27 +00:00
Keith Edmunds
b1442b2c7d WIP V3: check track already present in playlist when adding 2023-11-27 20:55:24 +00:00
Keith Edmunds
3cab9f737c WIP V3: click on current/next header scrolls to track 2023-11-27 16:16:33 +00:00
7 changed files with 197 additions and 553 deletions

View File

@ -79,12 +79,11 @@ class MusicMusterSignals(QObject):
https://refactoring.guru/design-patterns/singleton/python/example#example-0 https://refactoring.guru/design-patterns/singleton/python/example#example-0
""" """
add_track_to_header_signal = pyqtSignal(int, int, int)
add_track_to_playlist_signal = pyqtSignal(int, int, int, str)
begin_reset_model_signal = pyqtSignal(int) begin_reset_model_signal = pyqtSignal(int)
enable_escape_signal = pyqtSignal(bool) enable_escape_signal = pyqtSignal(bool)
end_reset_model_signal = pyqtSignal(int) end_reset_model_signal = pyqtSignal(int)
next_track_changed_signal = pyqtSignal() next_track_changed_signal = pyqtSignal()
resize_rows_signal = pyqtSignal(int)
row_order_changed_signal = pyqtSignal(int) row_order_changed_signal = pyqtSignal(int)
search_songfacts_signal = pyqtSignal(str) search_songfacts_signal = pyqtSignal(str)
search_wikipedia_signal = pyqtSignal(str) search_wikipedia_signal = pyqtSignal(str)

View File

@ -6,10 +6,12 @@ from PyQt6.QtWidgets import QDialog, QListWidgetItem
from classes import MusicMusterSignals from classes import MusicMusterSignals
from dbconfig import scoped_session from dbconfig import scoped_session
from helpers import ( from helpers import (
ask_yes_no,
get_relative_date, get_relative_date,
ms_to_mmss, ms_to_mmss,
) )
from models import Settings, Tracks from models import Settings, Tracks
from playlistmodel import PlaylistModel
from ui.dlg_TrackSelect_ui import Ui_Dialog # type: ignore from ui.dlg_TrackSelect_ui import Ui_Dialog # type: ignore
@ -20,7 +22,7 @@ class TrackSelectDialog(QDialog):
self, self,
session: scoped_session, session: scoped_session,
new_row_number: int, new_row_number: int,
playlist_id: int, model: PlaylistModel,
add_to_header: Optional[bool] = False, add_to_header: Optional[bool] = False,
*args, *args,
**kwargs, **kwargs,
@ -32,7 +34,7 @@ class TrackSelectDialog(QDialog):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.session = session self.session = session
self.new_row_number = new_row_number self.new_row_number = new_row_number
self.playlist_id = playlist_id self.model = model
self.add_to_header = add_to_header self.add_to_header = add_to_header
self.ui = Ui_Dialog() self.ui = Ui_Dialog()
self.ui.setupUi(self) self.ui.setupUi(self)
@ -73,14 +75,30 @@ class TrackSelectDialog(QDialog):
track_id = None track_id = None
if track: if track:
track_id = track.id track_id = track.id
if self.add_to_header:
self.signals.add_track_to_header_signal.emit(
self.playlist_id, self.new_row_number, track_id
)
else: else:
self.signals.add_track_to_playlist_signal.emit( return
self.playlist_id, self.new_row_number, track_id, note # Check whether track is already in playlist
) move_existing = False
existing_prd = self.model.is_track_in_playlist(track_id)
if existing_prd is not None:
if ask_yes_no(
"Duplicate row",
"Track already in playlist. " "Move to new location?",
default_yes=True,
):
move_existing = True
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:
self.model.add_track_to_header(self.new_row_number, track_id)
# 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
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)
def add_selected_and_close(self) -> None: def add_selected_and_close(self) -> None:
"""Handle Add and Close button""" """Handle Add and Close button"""

View File

@ -148,11 +148,11 @@ class ImportTrack(QObject):
import_finished = pyqtSignal() import_finished = pyqtSignal()
def __init__( def __init__(
self, filenames: List[str], playlist_id: int, row_number: Optional[int] self, filenames: List[str], model: PlaylistModel, row_number: Optional[int]
) -> None: ) -> None:
super().__init__() super().__init__()
self.filenames = filenames self.filenames = filenames
self.playlist_id = playlist_id self.model = model
self.next_row_number = row_number self.next_row_number = row_number
self.signals = MusicMusterSignals() self.signals = MusicMusterSignals()
@ -179,9 +179,7 @@ class ImportTrack(QObject):
# previous additions in this loop. So, commit now to # previous additions in this loop. So, commit now to
# lock in what we've just done. # lock in what we've just done.
session.commit() session.commit()
self.signals.add_track_to_playlist_signal.emit( self.model.insert_row(self.next_row_number, track.id, "")
self.playlist_id, self.next_row_number, track.id, ""
)
self.next_row_number += 1 self.next_row_number += 1
self.signals.status_message_signal.emit( self.signals.status_message_signal.emit(
f"{len(self.filenames)} tracks imported", 10000 f"{len(self.filenames)} tracks imported", 10000
@ -532,8 +530,6 @@ class Window(QMainWindow, Ui_MainWindow):
self.actionSelect_duplicate_rows.triggered.connect( self.actionSelect_duplicate_rows.triggered.connect(
lambda: self.active_tab().select_duplicate_rows() lambda: self.active_tab().select_duplicate_rows()
) )
self.actionSelect_next_track.triggered.connect(self.select_next_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)
self.actionSetNext.triggered.connect(self.set_selected_track_next) self.actionSetNext.triggered.connect(self.set_selected_track_next)
self.actionSkipToNext.triggered.connect(self.play_next) self.actionSkipToNext.triggered.connect(self.play_next)
@ -832,8 +828,8 @@ class Window(QMainWindow, Ui_MainWindow):
self.import_thread = QThread() self.import_thread = QThread()
self.worker = ImportTrack( self.worker = ImportTrack(
new_tracks, new_tracks,
self.active_tab().playlist_id, self.active_model(),
self.active_tab().get_selected_row_number(), self.active_tab().selected_model_row_number(),
) )
self.worker.moveToThread(self.import_thread) self.worker.moveToThread(self.import_thread)
self.import_thread.started.connect(self.worker.run) self.import_thread.started.connect(self.worker.run)
@ -871,8 +867,8 @@ class Window(QMainWindow, Ui_MainWindow):
with Session() as session: with Session() as session:
dlg = TrackSelectDialog( dlg = TrackSelectDialog(
session=session, session=session,
new_row_number=self.active_tab().get_selected_row_number(), new_row_number=self.active_tab().selected_model_row_number(),
playlist_id=self.active_tab().playlist_id, model=self.active_model(),
) )
dlg.exec() dlg.exec()
@ -893,7 +889,7 @@ class Window(QMainWindow, Ui_MainWindow):
Display songfacts page for title in highlighted row Display songfacts page for title in highlighted row
""" """
row_number = self.active_tab().get_selected_row_number() row_number = self.active_tab().selected_model_row_number()
if row_number is None: if row_number is None:
return return
@ -908,7 +904,7 @@ class Window(QMainWindow, Ui_MainWindow):
Display Wikipedia page for title in highlighted row Display Wikipedia page for title in highlighted row
""" """
row_number = self.active_tab().get_selected_row_number() row_number = self.active_tab().selected_model_row_number()
if row_number is None: if row_number is None:
return return
@ -1071,12 +1067,6 @@ class Window(QMainWindow, Ui_MainWindow):
# Clear next track # Clear next track
self.clear_next() self.clear_next()
# Set current track playlist_tab colour
# TODO Reimplement without reference to self.current_track.playlist_tab
# current_tab = self.current_track.playlist_tab
# if current_tab:
# self.set_tab_colour(current_tab, QColor(Config.COLOUR_CURRENT_TAB))
# Restore volume if -3dB active # Restore volume if -3dB active
if self.btnDrop3db.isChecked(): if self.btnDrop3db.isChecked():
self.btnDrop3db.setChecked(False) self.btnDrop3db.setChecked(False)
@ -1228,8 +1218,6 @@ class Window(QMainWindow, Ui_MainWindow):
def search_playlist_clear(self) -> None: def search_playlist_clear(self) -> None:
"""Tidy up and reset search bar""" """Tidy up and reset search bar"""
# Clear the search text
self.active_tab().set_search("")
# Clean up search bar # Clean up search bar
self.txtSearch.setText("") self.txtSearch.setText("")
self.txtSearch.setHidden(True) self.txtSearch.setHidden(True)
@ -1298,11 +1286,7 @@ class Window(QMainWindow, Ui_MainWindow):
def show_current(self) -> None: def show_current(self) -> None:
"""Scroll to show current track""" """Scroll to show current track"""
return self.show_track(track_sequence.now)
# TODO Reimplement
# if self.current_track.playlist_tab != self.active_tab():
# self.tabPlaylist.setCurrentWidget(self.current_track.playlist_tab)
# self.tabPlaylist.currentWidget().scroll_current_to_top()
def show_warning(self, title: str, body: str) -> None: def show_warning(self, title: str, body: str) -> None:
""" """
@ -1315,11 +1299,7 @@ class Window(QMainWindow, Ui_MainWindow):
def show_next(self) -> None: def show_next(self) -> None:
"""Scroll to show next track""" """Scroll to show next track"""
return self.show_track(track_sequence.next)
# TODO Reimplement
# if self.next_track.playlist_tab != self.active_tab():
# self.tabPlaylist.setCurrentWidget(self.next_track.playlist_tab)
# self.tabPlaylist.currentWidget().scroll_next_to_top()
def show_status_message(self, message: str, timing: int) -> None: def show_status_message(self, message: str, timing: int) -> None:
""" """
@ -1328,6 +1308,23 @@ class Window(QMainWindow, Ui_MainWindow):
self.statusbar.showMessage(message, timing) self.statusbar.showMessage(message, timing)
def show_track(self, plt: PlaylistTrack) -> None:
"""Scroll to show track in plt"""
# Switch to the correct tab
plt_playlist_id = plt.playlist_id
if not plt_playlist_id:
# No playlist
return
if plt_playlist_id != self.active_tab().playlist_id:
for idx in range(self.tabPlaylist.count()):
if self.tabPlaylist.widget(idx).playlist_id == plt_playlist_id:
self.tabPlaylist.setCurrentIndex(idx)
break
self.tabPlaylist.currentWidget().scroll_to_top(plt.plr_rownum)
def solicit_playlist_name( def solicit_playlist_name(
self, session: scoped_session, default: str = "" self, session: scoped_session, default: str = ""
) -> Optional[str]: ) -> Optional[str]:
@ -1417,85 +1414,6 @@ class Window(QMainWindow, Ui_MainWindow):
# Enable controls # Enable controls
self.enable_play_next_controls() self.enable_play_next_controls()
def set_next_plr_id(
self, next_plr_id: Optional[int], playlist_tab: PlaylistTab
) -> None:
"""
Set passed plr_id as next track to play, or clear next track if None
Actions required:
- Update playing_track
- Tell playlist tabs to update their 'next track' highlighting
- Update headers
- Set playlist tab colours
- Populate info tabs
"""
return
# with Session() as session:
# # Update self.next_track PlaylistTrack structure
# self.next_track = NextTrack()
# if next_plr_id:
# next_plr = session.get(PlaylistRows, next_plr_id)
# if next_plr:
# self.next_track.set_plr(session, next_plr)
# self.signals.set_next_track_signal.emit(next_plr.playlist_id)
# # Update headers
# self.update_headers()
# TODO: reimlement
# # Set playlist tab colours
# self._set_next_track_playlist_tab_colours(old_next_track)
# if next_plr_id:
# # Populate 'info' tabs with Wikipedia info, but queue it
# # because it isn't quick
# if self.next_track.title:
# QTimer.singleShot(
# 0,
# lambda: self.tabInfolist.open_in_wikipedia(
# self.next_track.title
# ),
# )
def _set_next_track_playlist_tab_colours(
self, old_next_track: Optional[PlaylistTrack]
) -> None:
"""
Set playlist tab colour for next track. self.next_track needs
to be set before calling.
"""
# If the original next playlist tab isn't the same as the
# new one or the current track, it needs its colour reset.
return
# TODO Reimplement
# if (
# old_next_track
# and old_next_track.playlist_tab
# and old_next_track.playlist_tab
# not in [self.next_track.playlist_tab, self.current_track.playlist_tab]
# ):
# self.set_tab_colour(
# old_next_track.playlist_tab, QColor(Config.COLOUR_NORMAL_TAB)
# )
# # If the new next playlist tab isn't the same as the
# # old one or the current track, it needs its colour set.
# if old_next_track:
# old_tab = old_next_track.playlist_tab
# else:
# old_tab = None
# if (
# self.next_track
# and self.next_track.playlist_tab
# and self.next_track.playlist_tab
# not in [old_tab, self.current_track.playlist_tab]
# ):
# self.set_tab_colour(
# self.next_track.playlist_tab, QColor(Config.COLOUR_NEXT_TAB)
# )
def tick_10ms(self) -> None: def tick_10ms(self) -> None:
""" """
Called every 10ms Called every 10ms

View File

@ -1,9 +1,11 @@
import obsws_python as obs # type: ignore
import re
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timedelta from datetime import datetime, timedelta
from enum import auto, Enum from enum import auto, Enum
from operator import attrgetter from operator import attrgetter
from pprint import pprint from pprint import pprint
from typing import cast, List, Optional from typing import List, Optional
from PyQt6.QtCore import ( from PyQt6.QtCore import (
QAbstractTableModel, QAbstractTableModel,
@ -35,6 +37,7 @@ from models import Playdates, PlaylistRows, Tracks
HEADER_NOTES_COLUMN = 1 HEADER_NOTES_COLUMN = 1
scene_change_re = re.compile(r"SetScene=\[([^[\]]*)\]")
class Col(Enum): class Col(Enum):
@ -124,8 +127,6 @@ class PlaylistModel(QAbstractTableModel):
self.signals = MusicMusterSignals() self.signals = MusicMusterSignals()
self.played_tracks_hidden = False self.played_tracks_hidden = False
self.signals.add_track_to_header_signal.connect(self.add_track_to_header)
self.signals.add_track_to_playlist_signal.connect(self.add_track)
self.signals.begin_reset_model_signal.connect(self.begin_reset_model) self.signals.begin_reset_model_signal.connect(self.begin_reset_model)
self.signals.end_reset_model_signal.connect(self.end_reset_model) self.signals.end_reset_model_signal.connect(self.end_reset_model)
self.signals.row_order_changed_signal.connect(self.row_order_changed) self.signals.row_order_changed_signal.connect(self.row_order_changed)
@ -142,45 +143,19 @@ class PlaylistModel(QAbstractTableModel):
f"<PlaylistModel: playlist_id={self.playlist_id}, {self.rowCount()} rows>" f"<PlaylistModel: playlist_id={self.playlist_id}, {self.rowCount()} rows>"
) )
def add_track(
self,
playlist_id: int,
new_row_number: int,
track_id: Optional[int],
note: Optional[str],
) -> None:
"""
Add track if it's for our playlist
"""
# Ignore if it's not for us
if playlist_id != self.playlist_id:
return
self.insert_row(
proposed_row_number=new_row_number, track_id=track_id, note=note
)
def add_track_to_header( def add_track_to_header(
self, self, row_number: int, track_id: int, note: Optional[str] = None
playlist_id: int,
row_number: int,
track_id: int,
) -> None: ) -> None:
""" """
Add track to existing header row if it's for our playlist Add track to existing header row
""" """
# Ignore if it's not for us
if playlist_id != self.playlist_id:
return
# Get existing row # Get existing row
try: try:
prd = self.playlist_rows[row_number] prd = self.playlist_rows[row_number]
except KeyError: except KeyError:
log.error( log.error(
f"KeyError in PlaylistModel:add_track_to_header ({playlist_id=}, " f"KeyError in PlaylistModel:add_track_to_header "
f"{row_number=}, {track_id=}, {len(self.playlist_rows)=}" f"{row_number=}, {track_id=}, {len(self.playlist_rows)=}"
) )
return return
@ -195,6 +170,9 @@ 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
if note:
plr.note += "\n" + note
# Reset header row spanning # Reset header row spanning
self.signals.span_cells_signal.emit( self.signals.span_cells_signal.emit(
row_number, HEADER_NOTES_COLUMN, 1, 1 row_number, HEADER_NOTES_COLUMN, 1, 1
@ -204,6 +182,8 @@ class PlaylistModel(QAbstractTableModel):
# Repaint row # Repaint row
self.invalidate_row(row_number) self.invalidate_row(row_number)
self.signals.resize_rows_signal.emit(self.playlist_id)
def background_role(self, row: int, column: int, prd: PlaylistRowData) -> QBrush: def background_role(self, row: int, column: int, prd: PlaylistRowData) -> QBrush:
"""Return background setting""" """Return background setting"""
@ -256,6 +236,7 @@ class PlaylistModel(QAbstractTableModel):
Actions required: Actions required:
- sanity check - sanity check
- change OBS scene if needed
- update display - update display
- update track times - update track times
- update Playdates in database - update Playdates in database
@ -278,6 +259,9 @@ class PlaylistModel(QAbstractTableModel):
) )
return return
# Check for OBS scene change
self.obs_scene_change(row_number)
# Update Playdates in database # Update Playdates in database
with Session() as session: with Session() as session:
Playdates(session, track_sequence.now.track_id) Playdates(session, track_sequence.now.track_id)
@ -596,6 +580,7 @@ class PlaylistModel(QAbstractTableModel):
""" """
count: int = 0 count: int = 0
unplayed_count: int = 0
duration: int = 0 duration: int = 0
if prd.note.endswith("+"): if prd.note.endswith("+"):
@ -613,6 +598,7 @@ class PlaylistModel(QAbstractTableModel):
else: else:
count += 1 count += 1
if not row_prd.played: if not row_prd.played:
unplayed_count += 1
duration += row_prd.duration duration += row_prd.duration
return ( return (
f"{prd.note[:-1].strip()} " f"{prd.note[:-1].strip()} "
@ -650,19 +636,21 @@ class PlaylistModel(QAbstractTableModel):
stripped_note = prd.note[:-1].strip() stripped_note = prd.note[:-1].strip()
if stripped_note: if stripped_note:
return ( return (
f"{stripped_note} [{count} track{'s' if count > 1 else ''}, " f"{stripped_note} ["
f"{ms_to_mmss(duration)} unplayed{end_time_str}]" f"{unplayed_count}/{count} track{'s' if count > 1 else ''} "
f"({ms_to_mmss(duration)}) unplayed{end_time_str}]"
) )
else: else:
return ( return (
f"[Subtotal: {count} track{'s' if count > 1 else ''}, " f"[{unplayed_count}/{count} track{'s' if count > 1 else ''} "
f"{ms_to_mmss(duration, none='none')} unplayed{end_time_str}]" f"({ms_to_mmss(duration)}) unplayed{end_time_str}]"
) )
else: else:
continue continue
else: else:
count += 1 count += 1
if not row_prd.played: if not row_prd.played:
unplayed_count += 1
duration += row_prd.duration duration += row_prd.duration
return prd.note return prd.note
@ -682,7 +670,9 @@ class PlaylistModel(QAbstractTableModel):
Return True if row is a header row, else False Return True if row is a header row, else False
""" """
return self.playlist_rows[row_number].path == "" 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: def is_played_row(self, row_number: int) -> bool:
""" """
@ -723,7 +713,8 @@ class PlaylistModel(QAbstractTableModel):
""" """
self.dataChanged.emit( self.dataChanged.emit(
self.index(modified_row, 0), self.index(modified_row, self.columnCount() - 1) self.index(modified_row, 0),
self.index(modified_row, self.columnCount() - 1),
) )
def invalidate_rows(self, modified_rows: List[int]) -> None: def invalidate_rows(self, modified_rows: List[int]) -> None:
@ -734,6 +725,18 @@ 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_track_in_playlist(self, track_id: int) -> Optional[PlaylistRowData]:
"""
If this track_id is in the playlist, return the PlaylistRowData object
else return None
"""
for row_number in range(len(self.playlist_rows)):
if self.playlist_rows[row_number].track_id == track_id:
return self.playlist_rows[row_number]
return None
def mark_unplayed(self, row_numbers: List[int]) -> None: def mark_unplayed(self, row_numbers: List[int]) -> None:
""" """
Mark row as unplayed Mark row as unplayed
@ -869,6 +872,75 @@ class PlaylistModel(QAbstractTableModel):
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()
def move_track_add_note(
self, new_row_number: int, existing_prd: PlaylistRowData, note: str
) -> None:
"""
Move existing_prd track to new_row_number and append note to any existing note
"""
if note:
with Session() as session:
plr = session.get(PlaylistRows, existing_prd.plrid)
if plr:
if plr.note:
plr.note += "\n" + note
else:
plr.note = note
# Carry out the move outside of the session context to ensure
# database updated with any note change
self.move_rows([existing_prd.plr_rownum], new_row_number)
self.signals.resize_rows_signal.emit(self.playlist_id)
def move_track_to_header(
self, header_row_number: int, existing_prd: PlaylistRowData, note: Optional[str]
) -> None:
"""
Add the existing_prd track details to the existing header at header_row_number
"""
if existing_prd.track_id:
if note and existing_prd.note:
note += "\n" + existing_prd.note
self.add_track_to_header(header_row_number, existing_prd.track_id, note)
self.delete_rows([existing_prd.plr_rownum])
def obs_scene_change(self, row_number: int) -> None:
"""
Check this row and any preceding headers for OBS scene change command
and execute any found
"""
# Check any headers before this row
idx = row_number - 1
while self.is_header_row(idx):
idx -= 1
# Step through headers in row order and finish with this row
for chkrow in range(idx + 1, row_number + 1):
match_obj = scene_change_re.search(self.playlist_rows[chkrow].note)
if match_obj:
scene_name = match_obj.group(1)
if scene_name:
try:
cl = obs.ReqClient(
host=Config.OBS_HOST,
port=Config.OBS_PORT,
password=Config.OBS_PASSWORD,
)
except ConnectionRefusedError:
log.error("OBS connection refused")
return
try:
cl.set_current_program_scene(scene_name)
log.info(f"OBS scene changed to '{scene_name}'")
continue
except obs.error.OBSSDKError as e:
log.error(f"OBS SDK error ({e})")
return
self.signals.resize_rows_signal.emit(self.playlist_id)
def open_in_audacity(self, row_number: int) -> None: def open_in_audacity(self, row_number: int) -> None:
""" """
Open track at passed row number in Audacity Open track at passed row number in Audacity
@ -902,9 +974,6 @@ class PlaylistModel(QAbstractTableModel):
# Update display # Update display
self.invalidate_row(track_sequence.previous.plr_rownum) self.invalidate_row(track_sequence.previous.plr_rownum)
# Update track times
# TODO
def refresh_data(self, session: scoped_session): def refresh_data(self, session: scoped_session):
"""Populate dicts for data calls""" """Populate dicts for data calls"""
@ -943,6 +1012,7 @@ class PlaylistModel(QAbstractTableModel):
set_track_metadata(track) set_track_metadata(track)
self.refresh_row(session, row_number) self.refresh_row(session, row_number)
self.invalidate_row(row_number) self.invalidate_row(row_number)
self.signals.resize_rows_signal.emit(self.playlist_id)
def _reversed_contiguous_row_groups( def _reversed_contiguous_row_groups(
self, row_numbers: List[int] self, row_numbers: List[int]
@ -1338,6 +1408,9 @@ class PlaylistProxyModel(QSortFilterProxyModel):
def is_played_row(self, row_number: int) -> bool: def is_played_row(self, row_number: int) -> bool:
return self.playlist_model.is_played_row(row_number) return self.playlist_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)
def mark_unplayed(self, row_numbers: List[int]) -> None: def mark_unplayed(self, row_numbers: List[int]) -> None:
return self.playlist_model.mark_unplayed(row_numbers) return self.playlist_model.mark_unplayed(row_numbers)
@ -1351,6 +1424,20 @@ class PlaylistProxyModel(QSortFilterProxyModel):
from_rows, to_row_number, to_playlist_id 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
)
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(
header_row_number, existing_prd, note
)
def open_in_audacity(self, row_number: int) -> None: def open_in_audacity(self, row_number: int) -> None:
return self.playlist_model.open_in_audacity(row_number) return self.playlist_model.open_in_audacity(row_number)

View File

@ -1,8 +1,3 @@
import subprocess
import obsws_python as obs # type: ignore
from datetime import datetime, timedelta
from pprint import pprint from pprint import pprint
from typing import Callable, cast, List, Optional, TYPE_CHECKING from typing import Callable, cast, List, Optional, TYPE_CHECKING
@ -11,11 +6,9 @@ from PyQt6.QtCore import (
QModelIndex, QModelIndex,
QObject, QObject,
QItemSelection, QItemSelection,
QItemSelectionModel,
Qt, Qt,
# QTimer,
) )
from PyQt6.QtGui import QAction, QDropEvent, QKeyEvent from PyQt6.QtGui import QAction, QKeyEvent
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QAbstractItemDelegate, QAbstractItemDelegate,
QAbstractItemView, QAbstractItemView,
@ -34,29 +27,21 @@ from PyQt6.QtWidgets import (
QStyleOption, QStyleOption,
) )
from dbconfig import Session, scoped_session from dbconfig import Session
from dialogs import TrackSelectDialog from dialogs import TrackSelectDialog
from classes import MusicMusterSignals, track_sequence from classes import MusicMusterSignals, track_sequence
from config import Config from config import Config
from helpers import ( from helpers import (
ask_yes_no, ask_yes_no,
file_is_unreadable,
get_relative_date,
ms_to_mmss, ms_to_mmss,
open_in_audacity,
send_mail,
set_track_metadata,
show_warning, show_warning,
) )
from log import log from models import Settings
from models import PlaylistRows, Settings, Tracks, NoteColours
if TYPE_CHECKING: if TYPE_CHECKING:
from musicmuster import Window from musicmuster import Window
from playlistmodel import PlaylistModel, PlaylistProxyModel from playlistmodel import PlaylistModel, PlaylistProxyModel
# HEADER_NOTES_COLUMN = 2
class EscapeDelegate(QStyledItemDelegate): class EscapeDelegate(QStyledItemDelegate):
""" """
@ -138,7 +123,7 @@ class EscapeDelegate(QStyledItemDelegate):
else: else:
edit_index = index edit_index = index
value = editor.toPlainText() value = editor.toPlainText().strip()
self.playlist_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):
@ -180,7 +165,6 @@ class PlaylistTab(QTableView):
self.proxy_model = PlaylistProxyModel(self.playlist_model) self.proxy_model = PlaylistProxyModel(self.playlist_model)
self.setItemDelegate(EscapeDelegate(self, self.playlist_model)) self.setItemDelegate(EscapeDelegate(self, self.playlist_model))
self.setAlternatingRowColors(True) self.setAlternatingRowColors(True)
# self.setEditTriggers(QAbstractItemView.EditTrigger.DoubleClicked)
self.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel) self.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
self.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove) self.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove)
self.setDragDropOverwriteMode(False) self.setDragDropOverwriteMode(False)
@ -203,15 +187,12 @@ class PlaylistTab(QTableView):
h_header.setStretchLastSection(True) h_header.setStretchLastSection(True)
# self.signals.set_next_track_signal.connect(self._reset_next) # self.signals.set_next_track_signal.connect(self._reset_next)
self.signals = MusicMusterSignals() self.signals = MusicMusterSignals()
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)
# Call self.eventFilter() for events
# self.installEventFilter(self)
# Initialise miscellaneous instance variables # Initialise miscellaneous instance variables
self.search_text: str = "" self.search_text: str = ""
self.sort_undo: List[int] = [] self.sort_undo: List[int] = []
# self.edit_cell_type: Optional[int]
# Selection model # Selection model
self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
@ -358,7 +339,7 @@ class PlaylistTab(QTableView):
return self.proxy_model.mapToSource(selected_index).row() return self.proxy_model.mapToSource(selected_index).row()
return selected_index.row() return selected_index.row()
def selected_model_row_numbers(self) -> Optional[List[int]]: def selected_model_row_numbers(self) -> List[int]:
""" """
Return a list of model row numbers corresponding to the selected rows or Return a list of model row numbers corresponding to the selected rows or
an empty list. an empty list.
@ -366,7 +347,7 @@ class PlaylistTab(QTableView):
selected_indexes = self._selected_row_indexes() selected_indexes = self._selected_row_indexes()
if selected_indexes is None: if selected_indexes is None:
return None return []
if hasattr(self.proxy_model, "mapToSource"): if hasattr(self.proxy_model, "mapToSource"):
return [self.proxy_model.mapToSource(a).row() for a in selected_indexes] return [self.proxy_model.mapToSource(a).row() for a in selected_indexes]
return [a.row() for a in selected_indexes] return [a.row() for a in selected_indexes]
@ -407,137 +388,6 @@ class PlaylistTab(QTableView):
return "" return ""
return self.playlist_model.get_row_track_path(model_row_number) return self.playlist_model.get_row_track_path(model_row_number)
# def lookup_row_in_songfacts(self) -> None:
# """
# If there is a selected row and it is a track row,
# look up its title in songfacts.
# If multiple rows are selected, only consider the first one.
# Otherwise return.
# """
# self._look_up_row(website="songfacts")
# def lookup_row_in_wikipedia(self) -> None:
# """
# If there is a selected row and it is a track row,
# look up its title in wikipedia.
# If multiple rows are selected, only consider the first one.
# Otherwise return.
# """
# self._look_up_row(website="wikipedia")
# def scroll_current_to_top(self) -> None:
# """Scroll currently-playing row to top"""
# current_row = self._get_current_track_row_number()
# if current_row is not None:
# self._scroll_to_top(current_row)
# def scroll_next_to_top(self) -> None:
# """Scroll nextly-playing row to top"""
# next_row = self._get_next_track_row_number()
# if next_row is not None:
# self._scroll_to_top(next_row)
def set_search(self, text: str) -> None:
"""Set search text and find first match"""
self.search_text = text
if not text:
# Search string has been reset
return
self._search(next=True)
# def search_next(self) -> None:
# """
# Select next row containg self.search_string.
# """
# self._search(next=True)
# def search_previous(self) -> None:
# """
# Select previous row containg self.search_string.
# """
# self._search(next=False)
# def select_next_row(self) -> None:
# """
# Select next or first row. Don't select section headers.
# Wrap at last row.
# """
# selected_rows = self._get_selected_rows()
# # we will only handle zero or one selected rows
# if len(selected_rows) > 1:
# return
# # select first row if none selected
# if len(selected_rows) == 0:
# row_number = 0
# else:
# row_number = selected_rows[0] + 1
# if row_number >= self.rowCount():
# row_number = 0
# # Don't select section headers
# wrapped = False
# track_id = self._get_row_track_id(row_number)
# while not track_id:
# row_number += 1
# if row_number >= self.rowCount():
# if wrapped:
# # we're already wrapped once, so there are no
# # non-headers
# return
# row_number = 0
# wrapped = True
# track_id = self._get_row_track_id(row_number)
# self.selectRow(row_number)
# def select_previous_row(self) -> None:
# """
# Select previous or last track. Don't select section headers.
# Wrap at first row.
# """
# selected_rows = self._get_selected_rows()
# # we will only handle zero or one selected rows
# if len(selected_rows) > 1:
# return
# # select last row if none selected
# last_row = self.rowCount() - 1
# if len(selected_rows) == 0:
# row_number = last_row
# else:
# row_number = selected_rows[0] - 1
# if row_number < 0:
# row_number = last_row
# # Don't select section headers
# wrapped = False
# track_id = self._get_row_track_id(row_number)
# while not track_id:
# row_number -= 1
# if row_number < 0:
# if wrapped:
# # we're already wrapped once, so there are no
# # non-notes
# return
# row_number = last_row
# wrapped = True
# track_id = self._get_row_track_id(row_number)
# self.selectRow(row_number)
def set_row_as_next_track(self) -> None: def set_row_as_next_track(self) -> None:
""" """
Set selected row as next track Set selected row as next track
@ -562,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,
playlist_id=self.playlist_id, model=self.playlist_model,
add_to_header=True, add_to_header=True,
) )
dlg.exec() dlg.exec()
@ -660,22 +510,12 @@ class PlaylistTab(QTableView):
if track_row: if track_row:
self._add_context_menu("Info", lambda: self._info_row(model_row_number)) self._add_context_menu("Info", lambda: self._info_row(model_row_number))
# Track path TODO # Track path
if track_row: if track_row:
self._add_context_menu( self._add_context_menu(
"Copy track path", lambda: self._copy_path(model_row_number) "Copy track path", lambda: self._copy_path(model_row_number)
) )
def _calculate_end_time(
self, start: Optional[datetime], duration: int
) -> Optional[datetime]:
"""Return datetime 'duration' ms after 'start'"""
if start is None:
return None
return start + timedelta(milliseconds=duration)
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.
@ -744,6 +584,7 @@ class PlaylistTab(QTableView):
return return
self.playlist_model.delete_rows(self.selected_model_row_numbers()) self.playlist_model.delete_rows(self.selected_model_row_numbers())
self.clear_selection()
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"""
@ -776,133 +617,19 @@ class PlaylistTab(QTableView):
info.setDefaultButton(QMessageBox.StandardButton.Cancel) info.setDefaultButton(QMessageBox.StandardButton.Cancel)
info.exec() info.exec()
def _look_up_row(self, website: str) -> None:
"""
If there is a selected row and it is a track row,
look up its title in the passed website
If multiple rows are selected, only consider the first one.
Otherwise return.
"""
print("playlists_v3:_look_up_row()")
return
# selected_row = self._get_selected_row()
# if not selected_row:
# return
# if not self._get_row_track_id(selected_row):
# return
# title = self._get_row_title(selected_row)
# if website == "wikipedia":
# QTimer.singleShot(
# 0, lambda: self.musicmuster.tabInfolist.open_in_wikipedia(title)
# )
# elif website == "songfacts":
# QTimer.singleShot(
# 0, lambda: self.musicmuster.tabInfolist.open_in_songfacts(title)
# )
# else:
# return
def _mark_as_unplayed(self, row_numbers: List[int]) -> None: def _mark_as_unplayed(self, row_numbers: List[int]) -> None:
"""Rescan track""" """Rescan track"""
self.playlist_model.mark_unplayed(row_numbers) self.playlist_model.mark_unplayed(row_numbers)
self.clear_selection() self.clear_selection()
def _obs_change_scene(self, current_row: int) -> None:
"""
Try to change OBS scene to the name passed
"""
check_row = current_row
while True:
# If we have a note and it has a scene change command,
# execute it
note_text = self._get_row_note(check_row)
if note_text:
match_obj = scene_change_re.search(note_text)
if match_obj:
scene_name = match_obj.group(1)
if scene_name:
try:
cl = obs.ReqClient(
host=Config.OBS_HOST,
port=Config.OBS_PORT,
password=Config.OBS_PASSWORD,
)
except ConnectionRefusedError:
log.error("OBS connection refused")
return
try:
cl.set_current_program_scene(scene_name)
log.info(f"OBS scene changed to '{scene_name}'")
return
except obs.error.OBSSDKError as e:
log.error(f"OBS SDK error ({e})")
return
# After current track row, only check header rows and stop
# at first non-header row
check_row -= 1
if check_row < 0:
break
if self._get_row_track_id(check_row):
break
def _rescan(self, row_number: int) -> None: def _rescan(self, row_number: int) -> None:
"""Rescan track""" """Rescan track"""
self.playlist_model.rescan_track(row_number) self.playlist_model.rescan_track(row_number)
self.clear_selection() self.clear_selection()
# def _reset_next(self, old_plrid: int, new_plrid: int) -> None: def scroll_to_top(self, row_number: int) -> None:
# """
# Called when set_next_track_signal signal received.
# Actions required:
# - If old_plrid points to this playlist:
# - Remove existing next track
# - If new_plrid points to this playlist:
# - Set track as next
# - Display row as next track
# - Update start/stop times
# """
# with Session() as session:
# # Get plrs
# old_plr = new_plr = None
# if old_plrid:
# old_plr = session.get(PlaylistRows, old_plrid)
# # Unmark next track
# if old_plr and old_plr.playlist_id == self.playlist_id:
# self._set_row_colour_default(old_plr.plr_rownum)
# # Mark next track
# if new_plrid:
# new_plr = session.get(PlaylistRows, new_plrid)
# if not new_plr:
# log.error(f"_reset_next({new_plrid=}): plr not found")
# return
# if new_plr.playlist_id == self.playlist_id:
# self._set_row_colour_next(new_plr.plr_rownum)
# # Update start/stop times
# self._update_start_end_times(session)
# self.clear_selection()
def _run_subprocess(self, args):
"""Run args in subprocess"""
subprocess.call(args)
def _scroll_to_top(self, row_number: int) -> None:
""" """
Scroll to put passed row_number Config.SCROLL_TOP_MARGIN from the Scroll to put passed row_number Config.SCROLL_TOP_MARGIN from the
top. top.
@ -911,83 +638,8 @@ class PlaylistTab(QTableView):
if row_number is None: if row_number is None:
return return
padding_required = Config.SCROLL_TOP_MARGIN row_index = self.proxy_model.index(row_number, 0)
top_row = row_number self.scrollTo(row_index, QAbstractItemView.ScrollHint.PositionAtTop)
if row_number > Config.SCROLL_TOP_MARGIN:
# We can't scroll to a hidden row. Calculate target_row as
# the one that is ideal to be at the top. Then count upwards
# from passed row_number until we either reach the target,
# pass it or reach row_number 0.
for i in range(row_number - 1, -1, -1):
if self.isRowHidden(i):
continue
if padding_required == 0:
break
top_row = i
padding_required -= 1
scroll_item = self.item(top_row, 0)
self.scrollToItem(scroll_item, QAbstractItemView.ScrollHint.PositionAtTop)
# def _search(self, next: bool = True) -> None:
# """
# Select next/previous row containg self.search_string. Start from
# top selected row if there is one, else from top.
# Wrap at last/first row.
# """
# if not self.search_text:
# return
# selected_row = self._get_selected_row()
# if next:
# if selected_row is not None and selected_row < self.rowCount() - 1:
# starting_row = selected_row + 1
# else:
# starting_row = 0
# else:
# if selected_row is not None and selected_row > 0:
# starting_row = selected_row - 1
# else:
# starting_row = self.rowCount() - 1
# wrapped = False
# match_row = None
# row_number = starting_row
# needle = self.search_text.lower()
# while True:
# # Check for match in title, artist or notes
# title = self._get_row_title(row_number)
# if title and needle in title.lower():
# match_row = row_number
# break
# artist = self._get_row_artist(row_number)
# if artist and needle in artist.lower():
# match_row = row_number
# break
# note = self._get_row_note(row_number)
# if note and needle in note.lower():
# match_row = row_number
# break
# if next:
# row_number += 1
# if wrapped and row_number >= starting_row:
# break
# if row_number >= self.rowCount():
# row_number = 0
# wrapped = True
# else:
# row_number -= 1
# if wrapped and row_number <= starting_row:
# break
# if row_number < 0:
# row_number = self.rowCount() - 1
# wrapped = True
# if match_row is not None:
# self.selectRow(row_number)
def select_duplicate_rows(self) -> None: def select_duplicate_rows(self) -> None:
""" """
@ -1052,30 +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_note_colour(self, session: scoped_session, row_number: int) -> None:
# """
# Set row note colour
# """
# # Sanity check: this should be a track row and thus have a
# # track associated
# if not self._get_row_track_id(row_number):
# if os.environ["MM_ENV"] == "PRODUCTION":
# send_mail(
# Config.ERRORS_TO,
# Config.ERRORS_FROM,
# "playlists:_set_row_note_colour() on header row",
# stackprinter.format(),
# )
# # stackprinter.show(add_summary=True, style="darkbg")
# print(f"playists:_set_row_note_colour() called on track row ({row_number=}")
# return
# # Set colour
# note_text = self._get_row_note(row_number)
# note_colour = NoteColours.get_colour(session, note_text)
# self._set_cell_colour(row_number, ROW_NOTES, note_colour)
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

View File

@ -786,9 +786,6 @@ padding-left: 8px;</string>
</property> </property>
<addaction name="actionSearch"/> <addaction name="actionSearch"/>
<addaction name="separator"/> <addaction name="separator"/>
<addaction name="actionSelect_next_track"/>
<addaction name="actionSelect_previous_track"/>
<addaction name="separator"/>
<addaction name="actionSearch_title_in_Wikipedia"/> <addaction name="actionSearch_title_in_Wikipedia"/>
<addaction name="actionSearch_title_in_Songfacts"/> <addaction name="actionSearch_title_in_Songfacts"/>
</widget> </widget>

View File

@ -493,9 +493,6 @@ class Ui_MainWindow(object):
self.menuPlaylist.addAction(self.actionPaste) self.menuPlaylist.addAction(self.actionPaste)
self.menuSearc_h.addAction(self.actionSearch) self.menuSearc_h.addAction(self.actionSearch)
self.menuSearc_h.addSeparator() self.menuSearc_h.addSeparator()
self.menuSearc_h.addAction(self.actionSelect_next_track)
self.menuSearc_h.addAction(self.actionSelect_previous_track)
self.menuSearc_h.addSeparator()
self.menuSearc_h.addAction(self.actionSearch_title_in_Wikipedia) self.menuSearc_h.addAction(self.actionSearch_title_in_Wikipedia)
self.menuSearc_h.addAction(self.actionSearch_title_in_Songfacts) self.menuSearc_h.addAction(self.actionSearch_title_in_Songfacts)
self.menuHelp.addAction(self.action_About) self.menuHelp.addAction(self.action_About)