Compare commits

..

3 Commits

Author SHA1 Message Date
Keith Edmunds
d9ad001c75 Relayout files
Created classes.py and moved common classes to classes.py. Ordered
imports.
2023-11-01 19:08:22 +00:00
Keith Edmunds
15ecae54cf Move MusicMusterSignals into helpers 2023-11-01 07:49:40 +00:00
Keith Edmunds
fedcfc3eea WIP V3: Add track to header row implemented 2023-10-31 20:09:45 +00:00
7 changed files with 669 additions and 580 deletions

176
app/classes.py Normal file
View File

@ -0,0 +1,176 @@
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Optional
from PyQt6.QtCore import pyqtSignal, QObject
import numpy as np
import pyqtgraph as pg # type: ignore
from config import Config
from dbconfig import scoped_session
from models import PlaylistRows
import helpers
class FadeCurve:
GraphWidget = None
def __init__(self, track):
"""
Set up fade graph array
"""
audio = helpers.get_audio_segment(track.path)
if not audio:
return None
# Start point of curve is Config.FADE_CURVE_MS_BEFORE_FADE
# milliseconds before fade starts to silence
self.start_ms = max(0, track.fade_at - Config.FADE_CURVE_MS_BEFORE_FADE - 1)
self.end_ms = track.silence_at
self.audio_segment = audio[self.start_ms : self.end_ms]
self.graph_array = np.array(self.audio_segment.get_array_of_samples())
# Calculate the factor to map milliseconds of track to array
self.ms_to_array_factor = len(self.graph_array) / (self.end_ms - self.start_ms)
self.region = None
def clear(self) -> None:
"""Clear the current graph"""
if self.GraphWidget:
self.GraphWidget.clear()
def plot(self):
self.curve = self.GraphWidget.plot(self.graph_array)
self.curve.setPen(Config.FADE_CURVE_FOREGROUND)
def tick(self, play_time) -> None:
"""Update volume fade curve"""
if not self.GraphWidget:
return
ms_of_graph = play_time - self.start_ms
if ms_of_graph < 0:
return
if self.region is None:
# Create the region now that we're into fade
self.region = pg.LinearRegionItem([0, 0], bounds=[0, len(self.graph_array)])
self.GraphWidget.addItem(self.region)
# Update region position
self.region.setRegion([0, ms_of_graph * self.ms_to_array_factor])
@helpers.singleton
@dataclass
class MusicMusterSignals(QObject):
"""
Class for all MusicMuster signals. See:
- https://zetcode.com/gui/pyqt5/eventssignals/
- https://stackoverflow.com/questions/62654525/
emit-a-signal-from-another-class-to-main-class
and Singleton class at
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)
enable_escape_signal = pyqtSignal(bool)
set_next_track_signal = pyqtSignal(int, int)
span_cells_signal = pyqtSignal(int, int, int, int)
def __post_init__(self):
super().__init__()
class PlaylistTrack:
"""
Used to provide a single reference point for specific playlist tracks,
typically the previous, current and next track.
"""
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
self.duration: Optional[int] = None
self.end_time: Optional[datetime] = None
self.fade_at: Optional[int] = None
self.fade_curve: Optional[FadeCurve] = None
self.fade_length: Optional[int] = None
self.path: Optional[str] = None
self.playlist_id: Optional[int] = None
self.plr_id: Optional[int] = None
self.silence_at: Optional[int] = None
self.start_gap: Optional[int] = None
self.start_time: Optional[datetime] = None
self.title: Optional[str] = None
self.track_id: Optional[int] = None
def __repr__(self) -> str:
return (
f"<PlaylistTrack(title={self.title}, artist={self.artist}, "
f"playlist_id={self.playlist_id}>"
)
def set_plr(self, session: scoped_session, plr: PlaylistRows) -> None:
"""
Update with new plr information
"""
if not plr.track:
return
session.add(plr)
track = plr.track
self.artist = track.artist
self.duration = track.duration
self.end_time = None
self.fade_at = track.fade_at
self.fade_graph = FadeCurve(track)
self.path = track.path
self.playlist_id = plr.playlist_id
self.plr_id = plr.id
self.silence_at = track.silence_at
self.start_gap = track.start_gap
self.start_time = None
self.title = track.title
self.track_id = track.id
if track.silence_at and track.fade_at:
self.fade_length = track.silence_at - track.fade_at
def start(self) -> None:
"""
Called when track starts playing
"""
self.start_time = datetime.now()
if self.duration:
self.end_time = self.start_time + timedelta(milliseconds=self.duration)
@helpers.singleton
class CurrentTrack(PlaylistTrack):
pass
@helpers.singleton
class NextTrack(PlaylistTrack):
pass
@helpers.singleton
class PreviousTrack(PlaylistTrack):
pass

View File

@ -1,20 +0,0 @@
from PyQt6.QtCore import pyqtSignal, QObject
from helpers import singleton
@singleton
class MusicMusterSignals(QObject):
"""
Class for all MusicMuster signals. See:
- https://zetcode.com/gui/pyqt5/eventssignals/
- https://stackoverflow.com/questions/62654525/
emit-a-signal-from-another-class-to-main-class
and Singleton class at
https://refactoring.guru/design-patterns/singleton/python/example#example-0
"""
enable_escape_signal = pyqtSignal(bool)
set_next_track_signal = pyqtSignal(int, int)
span_cells_signal = pyqtSignal(int, int, int, int)
add_track_to_playlist_signal = pyqtSignal(int, int, int, str)

178
app/dialogs.py Normal file
View File

@ -0,0 +1,178 @@
from typing import Optional
from PyQt6.QtCore import QEvent, Qt
from PyQt6.QtWidgets import QDialog, QListWidgetItem
from classes import MusicMusterSignals
from dbconfig import scoped_session
from helpers import (
get_relative_date,
ms_to_mmss,
)
from models import Settings, Tracks
from ui.dlg_TrackSelect_ui import Ui_Dialog # type: ignore
class TrackSelectDialog(QDialog):
"""Select track from database"""
def __init__(
self,
session: scoped_session,
new_row_number: int,
playlist_id: int,
add_to_header: Optional[bool] = False,
*args,
**kwargs,
) -> None:
"""
Subclassed QDialog to manage track selection
"""
super().__init__(*args, **kwargs)
self.session = session
self.new_row_number = new_row_number
self.playlist_id = playlist_id
self.add_to_header = add_to_header
self.ui = Ui_Dialog()
self.ui.setupUi(self)
self.ui.btnAdd.clicked.connect(self.add_selected)
self.ui.btnAddClose.clicked.connect(self.add_selected_and_close)
self.ui.btnClose.clicked.connect(self.close)
self.ui.matchList.itemDoubleClicked.connect(self.add_selected)
self.ui.matchList.itemSelectionChanged.connect(self.selection_changed)
self.ui.radioTitle.toggled.connect(self.title_artist_toggle)
self.ui.searchString.textEdited.connect(self.chars_typed)
self.track: Optional[Tracks] = None
self.signals = MusicMusterSignals()
record = Settings.get_int_settings(self.session, "dbdialog_width")
width = record.f_int or 800
record = Settings.get_int_settings(self.session, "dbdialog_height")
height = record.f_int or 600
self.resize(width, height)
def add_selected(self) -> None:
"""Handle Add button"""
track = None
if self.ui.matchList.selectedItems():
item = self.ui.matchList.currentItem()
if item:
track = item.data(Qt.ItemDataRole.UserRole)
note = self.ui.txtNote.text()
if not note and not track:
return
self.ui.txtNote.clear()
self.select_searchtext()
track_id = None
if track:
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:
self.signals.add_track_to_playlist_signal.emit(
self.playlist_id, self.new_row_number, track_id, note
)
def add_selected_and_close(self) -> None:
"""Handle Add and Close button"""
self.add_selected()
self.accept()
def chars_typed(self, s: str) -> None:
"""Handle text typed in search box"""
self.ui.matchList.clear()
if len(s) > 0:
if self.ui.radioTitle.isChecked():
matches = Tracks.search_titles(self.session, "%" + s)
else:
matches = Tracks.search_artists(self.session, "%" + s)
if matches:
for track in matches:
last_played = None
last_playdate = max(
track.playdates, key=lambda p: p.lastplayed, default=None
)
if last_playdate:
last_played = last_playdate.lastplayed
t = QListWidgetItem()
track_text = (
f"{track.title} - {track.artist} "
f"[{ms_to_mmss(track.duration)}] "
f"({get_relative_date(last_played)})"
)
t.setText(track_text)
t.setData(Qt.ItemDataRole.UserRole, track)
self.ui.matchList.addItem(t)
def closeEvent(self, event: Optional[QEvent]) -> None:
"""
Override close and save dialog coordinates
"""
if not event:
return
record = Settings.get_int_settings(self.session, "dbdialog_height")
if record.f_int != self.height():
record.update(self.session, {"f_int": self.height()})
record = Settings.get_int_settings(self.session, "dbdialog_width")
if record.f_int != self.width():
record.update(self.session, {"f_int": self.width()})
event.accept()
def keyPressEvent(self, event):
"""
Clear selection on ESC if there is one
"""
if event.key() == Qt.Key.Key_Escape:
if self.ui.matchList.selectedItems():
self.ui.matchList.clearSelection()
return
super(TrackSelectDialog, self).keyPressEvent(event)
def select_searchtext(self) -> None:
"""Select the searchbox"""
self.ui.searchString.selectAll()
self.ui.searchString.setFocus()
def selection_changed(self) -> None:
"""Display selected track path in dialog box"""
if not self.ui.matchList.selectedItems():
return
item = self.ui.matchList.currentItem()
track = item.data(Qt.ItemDataRole.UserRole)
last_playdate = max(track.playdates, key=lambda p: p.lastplayed, default=None)
if last_playdate:
last_played = last_playdate.lastplayed
else:
last_played = None
path_text = f"{track.path} ({get_relative_date(last_played)})"
self.ui.dbPath.setText(path_text)
def title_artist_toggle(self) -> None:
"""
Handle switching between searching for artists and searching for
titles
"""
# Logic is handled already in chars_typed(), so just call that.
self.chars_typed(self.ui.searchString.text())

View File

@ -1,3 +1,6 @@
from datetime import datetime
from email.message import EmailMessage
from typing import Any, Dict, Optional
import functools
import os
import psutil
@ -6,17 +9,18 @@ import smtplib
import ssl
import tempfile
from config import Config
from datetime import datetime
from email.message import EmailMessage
from log import log
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 tinytag import TinyTag # type: ignore
from typing import Any, Dict, Optional
from config import Config
from log import log
# Classes are defined after global functions so that classes can use
# those functions.
def ask_yes_no(title: str, question: str, default_yes: bool = False) -> bool:

View File

@ -1,18 +1,7 @@
#!/usr/bin/env python3
from log import log
from os.path import basename
import argparse
import os
import numpy as np
import pyqtgraph as pg # type: ignore
import stackprinter # type: ignore
import subprocess
import sys
import threading
from datetime import datetime, timedelta
from pygame import mixer
from os.path import basename
from time import sleep
from typing import (
cast,
@ -20,18 +9,20 @@ from typing import (
Optional,
Sequence,
)
import argparse
import os
import subprocess
import sys
import threading
from playlistmodel import PlaylistModel
from sqlalchemy import text
from pygame import mixer
from PyQt6.QtCore import (
pyqtSignal,
QDate,
QEvent,
QObject,
Qt,
QSize,
Qt,
QThread,
QTime,
QTimer,
@ -54,28 +45,39 @@ from PyQt6.QtWidgets import (
QListWidgetItem,
QMainWindow,
QMessageBox,
QPushButton,
QProgressBar,
QPushButton,
)
from sqlalchemy import text
import stackprinter # type: ignore
from classes import (
CurrentTrack,
FadeCurve,
MusicMusterSignals,
NextTrack,
PlaylistTrack,
PreviousTrack,
)
from config import Config
from dbconfig import (
engine,
Session,
scoped_session,
Session,
)
import helpers
import icons_rc # noqa F401
import music
from dialogs import TrackSelectDialog
from log import log
from models import Base, Carts, Playdates, PlaylistRows, Playlists, Settings, Tracks
from config import Config
from datastructures import MusicMusterSignals
from playlistmodel import PlaylistModel
from playlists import PlaylistTab
from ui.dlg_cart_ui import Ui_DialogCartEdit # type: ignore
from ui.dlg_TrackSelect_ui import Ui_Dialog # type: ignore
from ui.dlg_SelectPlaylist_ui import Ui_dlgSelectPlaylist # type: ignore
from ui.downloadcsv_ui import Ui_DateSelect # type: ignore
from ui.main_window_ui import Ui_MainWindow # type: ignore
from utilities import check_db, update_bitrates
import helpers
import icons_rc # noqa F401
import music
class CartButton(QPushButton):
@ -145,59 +147,6 @@ class CartButton(QPushButton):
self.pgb.setGeometry(0, 0, self.width(), 10)
class FadeCurve:
GraphWidget = None
def __init__(self, track):
"""
Set up fade graph array
"""
audio = helpers.get_audio_segment(track.path)
if not audio:
return None
# Start point of curve is Config.FADE_CURVE_MS_BEFORE_FADE
# milliseconds before fade starts to silence
self.start_ms = max(0, track.fade_at - Config.FADE_CURVE_MS_BEFORE_FADE - 1)
self.end_ms = track.silence_at
self.audio_segment = audio[self.start_ms : self.end_ms]
self.graph_array = np.array(self.audio_segment.get_array_of_samples())
# Calculate the factor to map milliseconds of track to array
self.ms_to_array_factor = len(self.graph_array) / (self.end_ms - self.start_ms)
self.region = None
def clear(self) -> None:
"""Clear the current graph"""
if self.GraphWidget:
self.GraphWidget.clear()
def plot(self):
self.curve = self.GraphWidget.plot(self.graph_array)
self.curve.setPen(Config.FADE_CURVE_FOREGROUND)
def tick(self, play_time) -> None:
"""Update volume fade curve"""
if not self.GraphWidget:
return
ms_of_graph = play_time - self.start_ms
if ms_of_graph < 0:
return
if self.region is None:
# Create the region now that we're into fade
self.region = pg.LinearRegionItem([0, 0], bounds=[0, len(self.graph_array)])
self.GraphWidget.addItem(self.region)
# Update region position
self.region.setRegion([0, ms_of_graph * self.ms_to_array_factor])
class ImportTrack(QObject):
import_error = pyqtSignal(str)
importing = pyqtSignal(str)
@ -238,84 +187,6 @@ class ImportTrack(QObject):
self.finished.emit(self.playlist)
class PlaylistTrack:
"""
Used to provide a single reference point for specific playlist tracks,
typically the previous, current and next track.
"""
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
self.duration: Optional[int] = None
self.end_time: Optional[datetime] = None
self.fade_at: Optional[int] = None
self.fade_curve: Optional[FadeCurve] = None
self.fade_length: Optional[int] = None
self.path: Optional[str] = None
self.playlist_id: Optional[int] = None
self.playlist_tab: Optional[PlaylistTab] = None
self.plr_id: Optional[int] = None
self.silence_at: Optional[int] = None
self.start_gap: Optional[int] = None
self.start_time: Optional[datetime] = None
self.title: Optional[str] = None
self.track_id: Optional[int] = None
def __repr__(self) -> str:
return (
f"<PlaylistTrack(title={self.title}, artist={self.artist}, "
f"playlist_id={self.playlist_id}>"
)
def set_plr(
self, session: scoped_session, plr: PlaylistRows, tab: PlaylistTab
) -> None:
"""
Update with new plr information
"""
if not plr.track:
return
self.playlist_tab = tab
session.add(plr)
track = plr.track
self.artist = track.artist
self.duration = track.duration
self.end_time = None
self.fade_at = track.fade_at
self.fade_graph = FadeCurve(track)
self.path = track.path
self.playlist_id = plr.playlist_id
self.plr_id = plr.id
self.silence_at = track.silence_at
self.start_gap = track.start_gap
self.start_time = None
self.title = track.title
self.track_id = track.id
if track.silence_at and track.fade_at:
self.fade_length = track.silence_at - track.fade_at
def start(self) -> None:
"""
Called when track starts playing
"""
self.start_time = datetime.now()
if self.duration:
self.end_time = self.start_time + timedelta(milliseconds=self.duration)
class Window(QMainWindow, Ui_MainWindow):
def __init__(self, parent=None, *args, **kwargs) -> None:
super().__init__(parent)
@ -328,9 +199,9 @@ class Window(QMainWindow, Ui_MainWindow):
self.music: music.Music = music.Music()
self.playing: bool = False
self.current_track = PlaylistTrack()
self.next_track = PlaylistTrack()
self.previous_track = PlaylistTrack()
self.current_track = CurrentTrack()
self.next_track = NextTrack()
self.previous_track = PreviousTrack()
self.previous_track_position: Optional[float] = None
self.selected_plrs: Optional[List[PlaylistRows]] = None
@ -522,7 +393,7 @@ class Window(QMainWindow, Ui_MainWindow):
Clear next track
"""
self.next_track = PlaylistTrack()
self.next_track = NextTrack()
self.update_headers()
def clear_selection(self) -> None:
@ -599,27 +470,29 @@ class Window(QMainWindow, Ui_MainWindow):
Return True if tab closed else False.
"""
# Don't close current track playlist
if self.tabPlaylist.widget(tab_index) == (self.current_track.playlist_tab):
self.statusbar.showMessage("Can't close current track playlist", 5000)
return False
# TODO Reimplement without ussing self.current_track.playlist_tab
# # Don't close current track playlist
# if self.tabPlaylist.widget(tab_index) == (self.current_track.playlist_tab):
# self.statusbar.showMessage("Can't close current track playlist", 5000)
# return False
# Attempt to close next track playlist
if self.tabPlaylist.widget(tab_index) == self.next_track.playlist_tab:
self.next_track.playlist_tab.clear_next()
# # Attempt to close next track playlist
# if self.tabPlaylist.widget(tab_index) == self.next_track.playlist_tab:
# self.next_track.playlist_tab.clear_next()
# Record playlist as closed and update remaining playlist tabs
with Session() as session:
playlist_id = self.tabPlaylist.widget(tab_index).playlist_id
playlist = session.get(Playlists, playlist_id)
if playlist:
playlist.close(session)
# # Record playlist as closed and update remaining playlist tabs
# with Session() as session:
# playlist_id = self.tabPlaylist.widget(tab_index).playlist_id
# playlist = session.get(Playlists, playlist_id)
# if playlist:
# playlist.close(session)
# Close playlist and remove tab
self.tabPlaylist.widget(tab_index).close()
self.tabPlaylist.removeTab(tab_index)
# # Close playlist and remove tab
# self.tabPlaylist.widget(tab_index).close()
# self.tabPlaylist.removeTab(tab_index)
return True
# return True
def connect_signals_slots(self) -> None:
self.action_About.triggered.connect(self.about)
@ -835,16 +708,17 @@ class Window(QMainWindow, Ui_MainWindow):
self.playing = False
# Tell playlist_tab track has finished
if self.current_track.playlist_tab:
self.current_track.playlist_tab.play_ended()
# TODO Reimplement as a signal
# if self.current_track.playlist_tab:
# self.current_track.playlist_tab.play_ended()
# Reset fade graph
self.current_track.fade_graph.clear()
# Reset PlaylistTrack objects
if self.current_track.track_id:
self.previous_track = self.current_track
self.current_track = PlaylistTrack()
self.previous_track = cast(PreviousTrack, self.current_track)
self.current_track = CurrentTrack()
# Reset clocks
self.frame_fade.setStyleSheet("")
@ -908,18 +782,6 @@ class Window(QMainWindow, Ui_MainWindow):
self.stop_playing(fade=True)
def get_one_track(self, session: scoped_session) -> Optional[Tracks]:
"""Show dialog box to select one track and return it to caller"""
dlg = TrackSelectDialog(self, session)
dlg.ui.txtNote.hide()
dlg.ui.lblNote.hide()
if dlg.exec():
return dlg.track
else:
return None
def get_playtime(self) -> int:
"""
Return number of milliseconds current track has been playing or
@ -1014,7 +876,7 @@ class Window(QMainWindow, Ui_MainWindow):
self.worker.finished.connect(self.import_complete)
self.import_thread.start()
def import_complete(self, playlist_tab: PlaylistTab):
def import_complete(self):
"""
Called by thread when track import complete
"""
@ -1303,7 +1165,7 @@ class Window(QMainWindow, Ui_MainWindow):
# Move next track to current track.
# stop_playing() above has called end_of_track_actions()
# which will have populated self.previous_track
self.current_track = self.next_track
self.current_track = cast(CurrentTrack, self.next_track)
self.clear_next()
if not self.current_track.track_id:
@ -1314,9 +1176,10 @@ class Window(QMainWindow, Ui_MainWindow):
return
# Set current track playlist_tab colour
current_tab = self.current_track.playlist_tab
if current_tab:
self.set_tab_colour(current_tab, QColor(Config.COLOUR_CURRENT_TAB))
# 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
if self.btnDrop3db.isChecked():
@ -1345,8 +1208,9 @@ class Window(QMainWindow, Ui_MainWindow):
Playdates(session, self.current_track.track_id)
# Tell playlist track is now playing
if self.current_track.playlist_tab:
self.current_track.playlist_tab.play_started(session)
# TODO Reimplement without reference to self.current_track.playlist_tab:
# if self.current_track.playlist_tab:
# self.current_track.playlist_tab.play_started(session)
# Note that track is now playing
self.playing = True
@ -1405,41 +1269,45 @@ class Window(QMainWindow, Ui_MainWindow):
- If a track is playing, make that the next track
"""
# Return if no saved position
if not self.previous_track_position:
return
# TODO Reimplement without reference to playlist_tab
# # Return if no saved position
# if not self.previous_track_position:
# return
# Note any playing track as this will become the next track
playing_track = None
if self.current_track.track_id:
playing_track = self.current_track
# # Note any playing track as this will become the next track
# playing_track = None
# if self.current_track.track_id:
# playing_track = self.current_track
# Set next plr to be track to resume
if not self.previous_track.plr_id:
return
if not self.previous_track.playlist_tab:
return
# # Set next plr to be track to resume
# if not self.previous_track.plr_id:
# return
# # TODO Reimplement following two lines
# # if not self.previous_track.playlist_tab:
# # return
# Resume last track
self.set_next_plr_id(
self.previous_track.plr_id, self.previous_track.playlist_tab
)
self.play_next(self.previous_track_position)
# # Resume last track
# # TODO Reimplement next four lines
# # self.set_next_plr_id(
# # self.previous_track.plr_id, self.previous_track.playlist_tab
# # )
# # self.play_next(self.previous_track_position)
# Adjust track info so that clocks and graph are correct.
# Easiest way is to fake the start time.
if self.current_track.start_time and self.current_track.duration:
elapsed_ms = self.current_track.duration * self.previous_track_position
self.current_track.start_time -= timedelta(milliseconds=elapsed_ms)
# # Adjust track info so that clocks and graph are correct.
# # Easiest way is to fake the start time.
# if self.current_track.start_time and self.current_track.duration:
# elapsed_ms = self.current_track.duration * self.previous_track_position
# self.current_track.start_time -= timedelta(milliseconds=elapsed_ms)
# If a track was playing when we were called, get details to
# set it as the next track
if playing_track:
if not playing_track.plr_id:
return
if not playing_track.playlist_tab:
return
self.set_next_plr_id(playing_track.plr_id, playing_track.playlist_tab)
# # If a track was playing when we were called, get details to
# # set it as the next track
# if playing_track:
# if not playing_track.plr_id:
# return
# if not playing_track.playlist_tab:
# return
# self.set_next_plr_id(playing_track.plr_id, playing_track.playlist_tab)
def save_as_template(self) -> None:
"""Save current playlist as template"""
@ -1565,16 +1433,20 @@ class Window(QMainWindow, Ui_MainWindow):
def show_current(self) -> None:
"""Scroll to show current track"""
if self.current_track.playlist_tab != self.active_tab():
self.tabPlaylist.setCurrentWidget(self.current_track.playlist_tab)
self.tabPlaylist.currentWidget().scroll_current_to_top()
return
# 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_next(self) -> None:
"""Scroll to show next track"""
if self.next_track.playlist_tab != self.active_tab():
self.tabPlaylist.setCurrentWidget(self.next_track.playlist_tab)
self.tabPlaylist.currentWidget().scroll_next_to_top()
return
# 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 solicit_playlist_name(self, default: Optional[str] = "") -> Optional[str]:
"""Get name of playlist from user"""
@ -1619,15 +1491,16 @@ class Window(QMainWindow, Ui_MainWindow):
self.music.stop()
# Reset playlist_tab colour
if self.current_track.playlist_tab:
if self.current_track.playlist_tab == self.next_track.playlist_tab:
self.set_tab_colour(
self.current_track.playlist_tab, QColor(Config.COLOUR_NEXT_TAB)
)
else:
self.set_tab_colour(
self.current_track.playlist_tab, QColor(Config.COLOUR_NORMAL_TAB)
)
# TODO Reimplement
# if self.current_track.playlist_tab:
# if self.current_track.playlist_tab == self.next_track.playlist_tab:
# self.set_tab_colour(
# self.current_track.playlist_tab, QColor(Config.COLOUR_NEXT_TAB)
# )
# else:
# self.set_tab_colour(
# self.current_track.playlist_tab, QColor(Config.COLOUR_NORMAL_TAB)
# )
# Run end-of-track actions
self.end_of_track_actions()
@ -1671,11 +1544,11 @@ class Window(QMainWindow, Ui_MainWindow):
with Session() as session:
# Update self.next_track PlaylistTrack structure
old_next_track = self.next_track
self.next_track = PlaylistTrack()
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, playlist_tab)
self.next_track.set_plr(session, next_plr)
# Tell playlist tabs to update their 'next track' highlighting
# Args must both be ints, so use zero for no next track
@ -1710,30 +1583,32 @@ class Window(QMainWindow, Ui_MainWindow):
# If the original next playlist tab isn't the same as the
# new one or the current track, it needs its colour reset.
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)
)
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:
"""
@ -1901,164 +1776,6 @@ class CartDialog(QDialog):
self.ui.lblPath.setText(self.path)
class TrackSelectDialog(QDialog):
"""Select track from database"""
def __init__(
self,
session: scoped_session,
new_row_number: int,
playlist_id: int,
*args,
**kwargs,
) -> None:
"""
Subclassed QDialog to manage track selection
"""
super().__init__(*args, **kwargs)
self.session = session
self.new_row_number = new_row_number
self.playlist_id = playlist_id
self.ui = Ui_Dialog()
self.ui.setupUi(self)
self.ui.btnAdd.clicked.connect(self.add_selected)
self.ui.btnAddClose.clicked.connect(self.add_selected_and_close)
self.ui.btnClose.clicked.connect(self.close)
self.ui.matchList.itemDoubleClicked.connect(self.add_selected)
self.ui.matchList.itemSelectionChanged.connect(self.selection_changed)
self.ui.radioTitle.toggled.connect(self.title_artist_toggle)
self.ui.searchString.textEdited.connect(self.chars_typed)
self.track: Optional[Tracks] = None
self.signals = MusicMusterSignals()
record = Settings.get_int_settings(self.session, "dbdialog_width")
width = record.f_int or 800
record = Settings.get_int_settings(self.session, "dbdialog_height")
height = record.f_int or 600
self.resize(width, height)
def add_selected(self) -> None:
"""Handle Add button"""
track = None
if self.ui.matchList.selectedItems():
item = self.ui.matchList.currentItem()
if item:
track = item.data(Qt.ItemDataRole.UserRole)
note = self.ui.txtNote.text()
if not note and not track:
return
self.ui.txtNote.clear()
self.select_searchtext()
track_id = None
if track:
track_id = track.id
self.signals.add_track_to_playlist_signal.emit(
self.playlist_id, self.new_row_number, track_id, note
)
def add_selected_and_close(self) -> None:
"""Handle Add and Close button"""
self.add_selected()
self.accept()
def chars_typed(self, s: str) -> None:
"""Handle text typed in search box"""
self.ui.matchList.clear()
if len(s) > 0:
if self.ui.radioTitle.isChecked():
matches = Tracks.search_titles(self.session, "%" + s)
else:
matches = Tracks.search_artists(self.session, "%" + s)
if matches:
for track in matches:
last_played = None
last_playdate = max(
track.playdates, key=lambda p: p.lastplayed, default=None
)
if last_playdate:
last_played = last_playdate.lastplayed
t = QListWidgetItem()
track_text = (
f"{track.title} - {track.artist} "
f"[{helpers.ms_to_mmss(track.duration)}] "
f"({helpers.get_relative_date(last_played)})"
)
t.setText(track_text)
t.setData(Qt.ItemDataRole.UserRole, track)
self.ui.matchList.addItem(t)
def closeEvent(self, event: Optional[QEvent]) -> None:
"""
Override close and save dialog coordinates
"""
if not event:
return
record = Settings.get_int_settings(self.session, "dbdialog_height")
if record.f_int != self.height():
record.update(self.session, {"f_int": self.height()})
record = Settings.get_int_settings(self.session, "dbdialog_width")
if record.f_int != self.width():
record.update(self.session, {"f_int": self.width()})
event.accept()
def keyPressEvent(self, event):
"""
Clear selection on ESC if there is one
"""
if event.key() == Qt.Key.Key_Escape:
if self.ui.matchList.selectedItems():
self.ui.matchList.clearSelection()
return
super(TrackSelectDialog, self).keyPressEvent(event)
def select_searchtext(self) -> None:
"""Select the searchbox"""
self.ui.searchString.selectAll()
self.ui.searchString.setFocus()
def selection_changed(self) -> None:
"""Display selected track path in dialog box"""
if not self.ui.matchList.selectedItems():
return
item = self.ui.matchList.currentItem()
track = item.data(Qt.ItemDataRole.UserRole)
last_playdate = max(track.playdates, key=lambda p: p.lastplayed, default=None)
if last_playdate:
last_played = last_playdate.lastplayed
else:
last_played = None
path_text = f"{track.path} ({helpers.get_relative_date(last_played)})"
self.ui.dbPath.setText(path_text)
def title_artist_toggle(self) -> None:
"""
Handle switching between searching for artists and searching for
titles
"""
# Logic is handled already in chars_typed(), so just call that.
self.chars_typed(self.ui.searchString.text())
class DownloadCSV(QDialog):
def __init__(self, parent=None):
super().__init__()

View File

@ -1,7 +1,6 @@
from datetime import datetime
from enum import auto, Enum
from sqlalchemy import bindparam, update
from typing import List, Optional, TYPE_CHECKING
from typing import List, Optional
from PyQt6.QtCore import (
QAbstractTableModel,
@ -15,12 +14,11 @@ from PyQt6.QtGui import (
QFont,
)
from classes import CurrentTrack, MusicMusterSignals, NextTrack
from config import Config
from datastructures import MusicMusterSignals
from dbconfig import scoped_session, Session
from helpers import (
file_is_unreadable,
)
from helpers import file_is_unreadable
from log import log
from models import PlaylistRows, Tracks
@ -104,6 +102,7 @@ class PlaylistModel(QAbstractTableModel):
self.signals = MusicMusterSignals()
self.signals.add_track_to_playlist_signal.connect(self.add_track)
self.signals.add_track_to_header_signal.connect(self.add_track_to_header)
with Session() as session:
self.refresh_data(session)
@ -138,6 +137,49 @@ class PlaylistModel(QAbstractTableModel):
# No track, no note, no point
return
def add_track_to_header(
self,
playlist_id: int,
row_number: int,
track_id: int,
) -> None:
"""
Add track to existing header row if it's for our playlist
"""
# Ignore if it's not for us
if playlist_id != self.playlist_id:
return
# Get existing row
try:
prd = self.playlist_rows[row_number]
except KeyError:
log.error(
f"KeyError in PlaylistModel:add_track_to_header ({playlist_id=}, "
f"{row_number=}, {track_id=}, {len(self.playlist_rows)=}"
)
return
if prd.path:
log.error(
f"Error in PlaylistModel:add_track_to_header ({prd=}, "
"Header row already has track associated"
)
return
with Session() as session:
plr = session.get(PlaylistRows, prd.plrid)
if plr:
# Add track to PlaylistRows
plr.track_id = track_id
# Reset header row spanning
self.signals.span_cells_signal.emit(
row_number, HEADER_NOTES_COLUMN, 1, 1
)
# Update local copy
self.refresh_row(session, row_number)
# Repaint row
self.invalidate_row(row_number)
def background_role(self, row: int, column: int, prd: PlaylistRowData) -> QBrush:
"""Return background setting"""
@ -321,6 +363,13 @@ class PlaylistModel(QAbstractTableModel):
return QVariant()
def is_header_row(self, row_number: int) -> bool:
"""
Return True if row is a header row, else False
"""
return self.playlist_rows[row_number].path == ""
def insert_header_row(self, row_number: Optional[int], text: str) -> None:
"""
Insert a header row.

View File

@ -7,7 +7,7 @@ import threading
import obsws_python as obs # type: ignore
from datetime import datetime, timedelta
from typing import Any, cast, List, Optional, Tuple, TYPE_CHECKING
from typing import Any, Callable, cast, List, Optional, Tuple, TYPE_CHECKING
from PyQt6.QtCore import (
QEvent,
@ -22,7 +22,7 @@ from PyQt6.QtWidgets import (
QAbstractItemView,
QApplication,
QHeaderView,
# QMenu,
QMenu,
QMessageBox,
QPlainTextEdit,
QStyledItemDelegate,
@ -35,8 +35,9 @@ from PyQt6.QtWidgets import (
QStyleOption,
)
from datastructures import MusicMusterSignals
from dbconfig import Session, scoped_session
from dialogs import TrackSelectDialog
from classes import MusicMusterSignals
from config import Config
from helpers import (
ask_yes_no,
@ -48,11 +49,11 @@ from helpers import (
set_track_metadata,
)
from log import log
from models import Playlists, PlaylistRows, Settings, Tracks, NoteColours
from playlistmodel import PlaylistModel
from models import PlaylistRows, Settings, Tracks, NoteColours
if TYPE_CHECKING:
from musicmuster import Window, MusicMusterSignals
from musicmuster import Window
from playlistmodel import PlaylistModel
HEADER_NOTES_COLUMN = 2
@ -78,7 +79,8 @@ class EscapeDelegate(QStyledItemDelegate):
Intercept createEditor call and make row just a little bit taller
"""
signals.enable_escape_signal.emit(False)
self.signals = MusicMusterSignals()
self.signals.enable_escape_signal.emit(False)
if isinstance(self.parent(), PlaylistTab):
p = cast(PlaylistTab, self.parent())
if isinstance(index.data(), str):
@ -111,7 +113,7 @@ class EscapeDelegate(QStyledItemDelegate):
return True
elif key_event.key() == Qt.Key.Key_Escape:
discard_edits = QMessageBox.question(
self.parent(), "Abandon edit", "Discard changes?"
cast(QWidget, self), "Abandon edit", "Discard changes?"
)
if discard_edits == QMessageBox.StandardButton.Yes:
self.closeEditor.emit(editor)
@ -134,8 +136,7 @@ class PlaylistStyle(QProxyStyle):
def drawPrimitive(self, element, option, painter, widget=None):
"""
Draw a line across the entire row rather than just the column
we're hovering over. This may not always work depending on global
style - for instance I think it won't work on OSX.
we're hovering over.
"""
if (
element == QStyle.PrimitiveElement.PE_IndicatorItemViewItemDrop
@ -178,9 +179,9 @@ class PlaylistTab(QTableView):
# rows selected
self.setDragEnabled(True)
# Prepare for context menu
# self.menu = QMenu()
# self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
# self.customContextMenuRequested.connect(self._context_menu)
self.menu = QMenu()
self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self.customContextMenuRequested.connect(self._context_menu)
# Connect signals
# This dancing is to satisfy mypy
@ -291,27 +292,27 @@ class PlaylistTab(QTableView):
# self.hide_or_show_played_tracks()
# def _add_context_menu(
# self,
# text: str,
# action: Callable,
# disabled: bool = False,
# parent_menu: Optional[QMenu] = None,
# ) -> Optional[QAction]:
# """
# Add item to self.menu
# """
def _add_context_menu(
self,
text: str,
action: Callable,
disabled: bool = False,
parent_menu: Optional[QMenu] = None,
) -> Optional[QAction]:
"""
Add item to self.menu
"""
# if parent_menu is None:
# parent_menu = self.menu
if parent_menu is None:
parent_menu = self.menu
# menu_item = parent_menu.addAction(text)
# if not menu_item:
# return None
# menu_item.setDisabled(disabled)
# menu_item.triggered.connect(action)
menu_item = parent_menu.addAction(text)
if not menu_item:
return None
menu_item.setDisabled(disabled)
menu_item.triggered.connect(action)
# return menu_item
return menu_item
# def mouseReleaseEvent(self, event):
# """
@ -1033,42 +1034,26 @@ class PlaylistTab(QTableView):
"""Add a track to a section header making it a normal track row"""
with Session() as session:
# Add track to playlist row
plr = self._get_row_plr(session, row_number)
if not plr:
return
dlg = TrackSelectDialog(
session=session,
new_row_number=row_number,
playlist_id=self.playlist_id,
add_to_header=True,
)
dlg.exec()
# Don't add track if there's already a track there
if plr.track_id is not None:
return
def _build_context_menu(self, item: QTableWidgetItem) -> None:
"""Used to process context (right-click) menu, which is defined here"""
# Get track
track = self.musicmuster.get_one_track(session)
if not track:
return
plr.track_id = track.id
# Reset row span
self.setSpan(row_number, HEADER_NOTES_COLUMN, 1, 1)
# Update attributes of row
self._update_row_track_info(session, row_number, track)
self._set_row_bold(row_number)
self._set_row_colour_default(row_number)
self._set_row_note_text(session, row_number, plr.note)
self.clear_selection()
self.save_playlist(session)
# Update times once display updated
self._update_start_end_times(session)
# def _build_context_menu(self, item: QTableWidgetItem) -> None:
# """Used to process context (right-click) menu, which is defined here"""
# self.menu.clear()
# row_number = item.row()
self.menu.clear()
row_number = item.row()
# track_id = self._get_row_track_id(row_number)
# track_row = bool(track_id)
# header_row = not track_row
header_row = False
model = cast(PlaylistModel, self.model())
if model:
header_row = model.is_header_row(row_number)
# current = row_number == self._get_current_track_row_number()
# next_row = row_number == self._get_next_track_row_number()
@ -1098,7 +1083,7 @@ class PlaylistTab(QTableView):
# )
# # ----------------------
# self.menu.addSeparator()
self.menu.addSeparator()
# # Remove row
# if not current and not next_row:
@ -1119,9 +1104,9 @@ class PlaylistTab(QTableView):
# "Remove track from row", lambda: self._remove_track(row_number)
# )
# # Add track to section header (ie, make this a track row)
# if header_row:
# self._add_context_menu("Add a track", lambda: self._add_track(row_number))
# Add track to section header (ie, make this a track row)
if header_row:
self._add_context_menu("Add a track", lambda: self._add_track(row_number))
# # Mark unplayed
# if self._get_row_userdata(row_number, self.PLAYED):
@ -1132,7 +1117,7 @@ class PlaylistTab(QTableView):
# self._add_context_menu("Unmark as next track", self.clear_next)
# # ----------------------
# self.menu.addSeparator()
self.menu.addSeparator()
# # Sort
# sort_menu = self.menu.addMenu("Sort")
@ -1198,12 +1183,12 @@ class PlaylistTab(QTableView):
record = Settings.get_int_settings(session, attr_name)
record.f_int = self.columnWidth(column_number)
# def _context_menu(self, pos):
# """Display right-click menu"""
def _context_menu(self, pos):
"""Display right-click menu"""
# item = self.itemAt(pos)
# self._build_context_menu(item)
# self.menu.exec(self.mapToGlobal(pos))
item = self.indexAt(pos)
self._build_context_menu(item)
self.menu.exec(self.mapToGlobal(pos))
def _copy_path(self, row_number: int) -> None:
"""