#!/usr/bin/env python from log import log import argparse import stackprinter # type: ignore import subprocess import sys import threading from datetime import datetime, timedelta from time import sleep from typing import List, Optional from PyQt5.QtCore import pyqtSignal, QDate, QEvent, Qt, QSize, QTime, QTimer from PyQt5.QtGui import QColor, QFont, QPalette, QResizeEvent from PyQt5.QtWidgets import ( QApplication, QDialog, QFileDialog, QInputDialog, QLabel, QLineEdit, QListWidgetItem, QMainWindow, QMessageBox, QPushButton, QProgressBar, ) from dbconfig import engine, Session import helpers import music from models import ( Base, Carts, Playdates, PlaylistRows, Playlists, Settings, Tracks ) from config import Config from playlists import PlaylistTab from ui.dlg_cart_ui import Ui_DialogCartEdit # type: ignore from ui.dlg_search_database_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 class CartButton(QPushButton): """Button for playing carts""" progress = pyqtSignal(int) def __init__(self, parent: QMainWindow, cart: Carts): """Create a cart pushbutton and set it disabled""" super().__init__(parent) self.parent = parent self.cart_id = cart.id if cart.path and cart.enabled and not cart.duration: tags = helpers.get_tags(cart.path) cart.duration = tags['duration'] self.duration = cart.duration self.path = cart.path self.player = None self.is_playing = False self.setEnabled(True) self.setMinimumSize(QSize(147, 61)) font = QFont() font.setPointSize(14) self.setFont(font) self.setObjectName("cart_" + str(cart.cart_number)) self.pgb = QProgressBar(self, textVisible=False) self.pgb.setVisible(False) palette = self.pgb.palette() palette.setColor(QPalette.Highlight, QColor(Config.COLOUR_CART_PROGRESSBAR)) self.pgb.setPalette(palette) self.pgb.setGeometry(0, 0, self.width(), 10) self.pgb.setMinimum(0) self.pgb.setMaximum(1) self.pgb.setValue(0) self.progress.connect(self.pgb.setValue) def __repr__(self) -> str: return ( f"" ) def event(self, event: QEvent) -> bool: """Allow right click even when button is disabled""" if event.type() == QEvent.MouseButtonRelease: if event.button() == Qt.RightButton: self.parent.cart_edit(self, event) return True return super().event(event) def resizeEvent(self, event: QResizeEvent) -> None: """Resize progess bar when button size changes""" self.pgb.setGeometry(0, 0, self.width(), 10) class TrackData: def __init__(self, track): self.id = track.id self.title = track.title self.artist = track.artist self.duration = track.duration self.start_gap = track.start_gap self.fade_at = track.fade_at self.silence_at = track.silence_at self.path = track.path self.mtime = track.mtime def __repr__(self) -> str: return ( f"" ) class Window(QMainWindow, Ui_MainWindow): def __init__(self, parent=None) -> None: super().__init__(parent) self.setupUi(self) self.timer: QTimer = QTimer() self.even_tick: bool = True self.playing: bool = False self.music: music.Music = music.Music() self.current_track: Optional[TrackData] = None self.current_track_playlist_tab: Optional[PlaylistTab] = None self.current_track_end_time = None self.next_track: Optional[TrackData] = None self.next_track_playlist_tab: Optional[PlaylistTab] = None self.previous_track: Optional[TrackData] = None self.previous_track_playlist_tab: Optional[PlaylistTab] = None self.previous_track_position: Optional[int] = None self.selected_plrs = None # Set colours that will be used by playlist row stripes palette = QPalette() palette.setColor(QPalette.Base, QColor(Config.COLOUR_EVEN_PLAYLIST)) palette.setColor(QPalette.AlternateBase, QColor(Config.COLOUR_ODD_PLAYLIST)) self.setPalette(palette) self.set_main_window_size() self.lblSumPlaytime = QLabel("") self.statusbar.addPermanentWidget(self.lblSumPlaytime) self.txtSearch = QLineEdit() self.statusbar.addWidget(self.txtSearch) self.txtSearch.setHidden(True) self.hide_played_tracks = False self.visible_playlist_tab: Callable[[], PlaylistTab] = \ self.tabPlaylist.currentWidget self.load_last_playlists() if Config.CARTS_HIDE: self.cartsWidget.hide() self.frame_6.hide() else: self.carts_init() self.enable_play_next_controls() self.timer.start(Config.TIMER_MS) self.connect_signals_slots() def cart_configure(self, cart: Carts, btn: CartButton) -> None: """Configure button with cart data""" btn.setEnabled(False) btn.pgb.setVisible(False) if cart.path: if helpers.file_is_readable(cart.path): colour = Config.COLOUR_CART_READY btn.path = cart.path btn.player = self.music.VLC.media_player_new(cart.path) btn.player.audio_set_volume(Config.VOLUME_VLC_DEFAULT) if cart.enabled: btn.setEnabled(True) btn.pgb.setVisible(True) else: colour = Config.COLOUR_CART_ERROR else: colour = Config.COLOUR_CART_UNCONFIGURED btn.setStyleSheet("background-color: " + colour + ";\n") btn.setText(cart.name) def cart_click(self) -> None: """Handle cart click""" btn = self.sender() if helpers.file_is_readable(btn.path): # Don't allow clicks while we're playing btn.setEnabled(False) btn.player.play() btn.is_playing = True colour = Config.COLOUR_CART_PLAYING thread = threading.Thread(target=self.cart_progressbar, args=(btn,)) thread.start() else: colour = Config.COLOUR_CART_ERROR btn.setStyleSheet("background-color: " + colour + ";\n") btn.pgb.minimum = 0 def cart_edit(self, btn: CartButton, event: QEvent): """Handle context menu for cart button""" with Session() as session: cart = session.query(Carts).get(btn.cart_id) if cart is None: log.ERROR("cart_edit: cart not found") return dlg = CartDialog(parent=self, session=session, cart=cart) if dlg.exec(): name = dlg.ui.lineEditName.text() if not name: QMessageBox.warning(self, "Error", "Name required") return path = dlg.path if not path: QMessageBox.warning(self, "Error", "Filename required") return if cart.path and helpers.file_is_readable(cart.path): tags = helpers.get_tags(cart.path) cart.duration = tags['duration'] cart.enabled = dlg.ui.chkEnabled.isChecked() cart.name = name cart.path = path session.add(cart) session.commit() self.cart_configure(cart, btn) def carts_init(self) -> None: """Initialse carts data structures""" with Session() as session: # Number carts from 1 for humanity for cart_number in range(1, Config.CARTS_COUNT + 1): cart = session.query(Carts).get(cart_number) if cart is None: cart = Carts(session, cart_number, name=f"Cart #{cart_number}") btn = CartButton(self, cart) btn.clicked.connect(self.cart_click) # Insert button on left of cart space starting at # location zero self.horizontalLayout_Carts.insertWidget(cart.id - 1, btn) # Configure button self.cart_configure(cart, btn) def cart_progressbar(self, btn: CartButton) -> None: """Manage progress bar""" ms = 0 btn.pgb.setMaximum(btn.duration) while ms <= btn.duration: btn.progress.emit(ms) ms += 100 sleep(0.1) def cart_tick(self) -> None: """Cart clock actions""" for i in range(self.horizontalLayout_Carts.count()): btn = self.horizontalLayout_Carts.itemAt(i).widget() if not btn: continue if btn.is_playing: if not btn.player.is_playing(): # Cart has finished playing btn.is_playing = False btn.setEnabled(True) # Setting to position 0 doesn't seem to work btn.player = self.music.VLC.media_player_new(btn.path) btn.player.audio_set_volume(Config.VOLUME_VLC_DEFAULT) colour = Config.COLOUR_CART_READY btn.setStyleSheet("background-color: " + colour + ";\n") btn.pgb.setValue(0) def clear_selection(self) -> None: """ Clear selected row""" # Unselect any selected rows if self.visible_playlist_tab(): self.visible_playlist_tab().clear_selection() # Clear the search bar self.search_playlist_clear() def closeEvent(self, event: QEvent) -> None: """Handle attempt to close main window""" # Don't allow window to close when a track is playing if self.music.player and self.music.player.is_playing(): event.ignore() helpers.show_warning( "Track playing", "Can't close application while track is playing") else: with Session() as session: record = Settings.get_int_settings( session, "mainwindow_height") if record.f_int != self.height(): record.update(session, {'f_int': self.height()}) record = Settings.get_int_settings(session, "mainwindow_width") if record.f_int != self.width(): record.update(session, {'f_int': self.width()}) record = Settings.get_int_settings(session, "mainwindow_x") if record.f_int != self.x(): record.update(session, {'f_int': self.x()}) record = Settings.get_int_settings(session, "mainwindow_y") if record.f_int != self.y(): record.update(session, {'f_int': self.y()}) # Save splitter settings splitter_sizes = self.splitter.sizes() assert len(splitter_sizes) == 2 splitter_top, splitter_bottom = splitter_sizes record = Settings.get_int_settings(session, "splitter_top") if record.f_int != splitter_top: record.update(session, {'f_int': splitter_top}) record = Settings.get_int_settings(session, "splitter_bottom") if record.f_int != splitter_bottom: record.update(session, {'f_int': splitter_bottom}) # Save current tab record = Settings.get_int_settings(session, "active_tab") record.update(session, {'f_int': self.tabPlaylist.currentIndex()}) event.accept() def close_playlist_tab(self) -> None: """ Close active playlist tab, called by menu item """ self.close_tab(self.tabPlaylist.currentIndex()) def close_tab(self, tab_index: int) -> None: """ Close playlist tab unless it holds the current or next track. Called from close_playlist_tab() or by clicking close button on 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 # Don't close next track playlist if self.tabPlaylist.widget(tab_index) == self.next_track_playlist_tab: self.statusbar.showMessage( "Can't close next track playlist", 5000) return # 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) playlist.close(session) # Close playlist and remove tab self.tabPlaylist.widget(tab_index).close() self.tabPlaylist.removeTab(tab_index) def connect_signals_slots(self) -> None: self.action_About.triggered.connect(self.about) self.action_Clear_selection.triggered.connect(self.clear_selection) self.actionDebug.triggered.connect(self.debug) self.actionClosePlaylist.triggered.connect(self.close_playlist_tab) self.actionDownload_CSV_of_played_tracks.triggered.connect( self.download_played_tracks) self.actionEnable_controls.triggered.connect( self.enable_play_next_controls) self.actionExport_playlist.triggered.connect(self.export_playlist_tab) self.actionFade.triggered.connect(self.fade) self.actionFind_next.triggered.connect( lambda: self.tabPlaylist.currentWidget().search_next()) self.actionFind_previous.triggered.connect( lambda: self.tabPlaylist.currentWidget().search_previous()) self.actionImport.triggered.connect(self.import_track) self.actionInsertSectionHeader.triggered.connect(self.insert_header) self.actionInsertTrack.triggered.connect(self.insert_track) self.actionMark_for_moving.triggered.connect(self.cut_rows) self.actionMoveSelected.triggered.connect(self.move_selected) self.actionNew_from_template.triggered.connect(self.new_from_template) self.actionNewPlaylist.triggered.connect(self.create_and_show_playlist) self.actionOpenPlaylist.triggered.connect(self.open_playlist) self.actionPaste.triggered.connect(self.paste_rows) self.actionPlay_next.triggered.connect(self.play_next) self.actionResume.triggered.connect(self.resume) self.actionSave_as_template.triggered.connect(self.save_as_template) self.actionSearch.triggered.connect(self.search_playlist) 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.actionSetNext.triggered.connect( lambda: self.tabPlaylist.currentWidget().set_selected_as_next()) self.actionSkipToNext.triggered.connect(self.play_next) self.actionStop.triggered.connect(self.stop) self.btnDrop3db.clicked.connect(self.drop3db) self.btnFade.clicked.connect(self.fade) self.btnHidePlayed.clicked.connect(self.hide_played) self.btnStop.clicked.connect(self.stop) self.hdrCurrentTrack.clicked.connect(self.show_current) self.hdrNextTrack.clicked.connect(self.show_next) self.tabPlaylist.currentChanged.connect(self.tab_change) self.tabPlaylist.tabCloseRequested.connect(self.close_tab) self.tabBar = self.tabPlaylist.tabBar() self.tabBar.tabMoved.connect(self.move_tab) self.txtSearch.returnPressed.connect(self.search_playlist_return) self.timer.timeout.connect(self.tick) def create_playlist(self, session: Session, playlist_name: Optional[str] = None) -> Playlists: """Create new playlist""" if not playlist_name: playlist_name = self.solicit_playlist_name() if not playlist_name: return playlist = Playlists(session, playlist_name) return playlist def create_and_show_playlist(self) -> None: """Create new playlist and display it""" with Session() as session: playlist = self.create_playlist(session) if playlist: self.create_playlist_tab(session, playlist) def create_playlist_tab(self, session: Session, playlist: Playlists) -> int: """ Take the passed playlist database object, create a playlist tab and add tab to display. Return index number of tab. """ playlist_tab = PlaylistTab( musicmuster=self, session=session, playlist_id=playlist.id) idx = self.tabPlaylist.addTab(playlist_tab, playlist.name) self.tabPlaylist.setCurrentIndex(idx) return idx def cut_rows(self) -> None: """ Cut rows ready for pasting. """ with Session() as session: # Save the selected PlaylistRows items ready for a later # paste self.selected_plrs = ( self.visible_playlist_tab().get_selected_playlistrows(session)) def debug(self): """Invoke debugger""" import ipdb # type: ignore ipdb.set_trace() def disable_play_next_controls(self) -> None: """ Disable "play next" keyboard controls """ self.actionPlay_next.setEnabled(False) self.statusbar.showMessage("Play controls: Disabled", 0) def download_played_tracks(self) -> None: """Download a CSV of played tracks""" dlg = DownloadCSV(self) if dlg.exec(): start_dt = dlg.ui.dateTimeEdit.dateTime().toPyDateTime() # Get output filename pathspec = QFileDialog.getSaveFileName( self, 'Save CSV of tracks played', directory="/tmp/playlist.csv", filter="CSV files (*.csv)" ) if not pathspec: return path = pathspec[0] if not path.endswith(".csv"): path += ".csv" with open(path, "w") as f: with Session() as session: for playdate in Playdates.played_after(session, start_dt): f.write( f"{playdate.track.artist},{playdate.track.title}\n" ) def drop3db(self) -> None: """Drop music level by 3db if button checked""" if self.btnDrop3db.isChecked(): self.music.set_volume(Config.VOLUME_VLC_DROP3db, set_default=False) else: self.music.set_volume(Config.VOLUME_VLC_DEFAULT, set_default=False) def enable_play_next_controls(self) -> None: """ Enable "play next" keyboard controls """ self.actionPlay_next.setEnabled(True) self.statusbar.showMessage("Play controls: Enabled", 0) def end_of_track_actions(self) -> None: """ Clean up after track played Actions required: - Set flag to say we're not playing a track - Reset current track - Tell playlist_tab track has finished - Reset current playlist_tab - Reset clocks - Reset end time - Update headers - Enable controls """ # Set flag to say we're not playing a track so that tick() # doesn't see player=None and kick off end-of-track actions self.playing = False # Reset current track if self.current_track: self.previous_track = self.current_track self.current_track = None # Tell playlist_tab track has finished and # reset current playlist_tab if self.current_track_playlist_tab: self.previous_track_playlist_tab = self.current_track_playlist_tab self.current_track_playlist_tab.play_stopped() self.current_track_playlist_tab = None # Reset clocks self.frame_fade.setStyleSheet("") self.frame_silent.setStyleSheet("") self.label_elapsed_timer.setText("00:00") self.label_end_timer.setText("00:00") self.label_fade_timer.setText("00:00") self.label_silent_timer.setText("00:00") self.label_start_time.setText("00:00:00") self.label_end_time.setText("00:00:00") if self.next_track: self.label_track_length.setText( helpers.ms_to_mmss(self.next_track.duration) ) self.label_fade_length.setText(helpers.ms_to_mmss( self.next_track.silence_at - self.next_track.fade_at)) else: self.label_track_length.setText("0:00") self.label_fade_length.setText("0:00") # Reset end time self.current_track_end_time = None # Update headers self.update_headers() # Enable controls self.enable_play_next_controls() def export_playlist_tab(self) -> None: """Export the current playlist to an m3u file""" if not self.visible_playlist_tab(): return playlist_id = self.visible_playlist_tab().playlist_id with Session() as session: # Get output filename playlist = session.get(Playlists, playlist_id) pathspec = QFileDialog.getSaveFileName( self, 'Save Playlist', directory=f"{playlist.name}.m3u", filter="M3U files (*.m3u);;All files (*.*)" ) if not pathspec: return path = pathspec[0] if not path.endswith(".m3u"): path += ".m3u" # Get list of track rows for this playlist plrs = PlaylistRows.get_rows_with_tracks(session, playlist_id) with open(path, "w") as f: # Required directive on first line f.write("#EXTM3U\n") for track in [a.track for a in plrs]: f.write( "#EXTINF:" f"{int(track.duration / 1000)}," f"{track.title} - " f"{track.artist}" "\n" f"{track.path}" "\n" ) def fade(self) -> None: """Fade currently playing track""" self.stop_playing(fade=True) def about(self) -> None: """Get git tag and database name""" try: git_tag = str( subprocess.check_output( ['git', 'describe'], stderr=subprocess.STDOUT ) ).strip('\'b\\n') except subprocess.CalledProcessError as exc_info: git_tag = str(exc_info.output) with Session() as session: dbname = session.bind.engine.url.database QMessageBox.information( self, "About", f"MusicMuster {git_tag}\n\nDatabase: {dbname}", QMessageBox.Ok ) def get_one_track(self, session: Session) -> Optional[Tracks]: """Show dialog box to select one track and return it to caller""" dlg = DbDialog(self, session, get_one_track=True) if dlg.exec(): return dlg.ui.track def hide_played(self): """Toggle hide played tracks""" if self.hide_played_tracks: self.hide_played_tracks = False self.btnHidePlayed.setText("Hide played") else: self.hide_played_tracks = True self.btnHidePlayed.setText("Show played") # Update all displayed playlists with Session() as session: for i in range(self.tabPlaylist.count()): self.tabPlaylist.widget(i).update_display(session) def import_track(self) -> None: """Import track file""" dlg = QFileDialog() dlg.setFileMode(QFileDialog.ExistingFiles) dlg.setViewMode(QFileDialog.Detail) dlg.setDirectory(Config.IMPORT_DESTINATION) dlg.setNameFilter("Music files (*.flac *.mp3)") if not dlg.exec_(): return with Session() as session: new_tracks = [] for fname in dlg.selectedFiles(): txt = "" tags = helpers.get_tags(fname) new_tracks.append((fname, tags)) title = tags['title'] artist = tags['artist'] count = 0 possible_matches = Tracks.search_titles(session, title) if possible_matches: txt += 'Similar to new track ' txt += f'"{title}" by "{artist} ({fname})":\n\n' for track in possible_matches: txt += f' "{track.title}" by {track.artist}' txt += f' ({track.path})\n\n' count += 1 if count >= Config.MAX_IMPORT_MATCHES: txt += "\nThere are more similar-looking tracks" break txt += "\n" # Check whether to proceed if there were potential matches txt += "Proceed with import?" result = QMessageBox.question(self, "Possible duplicates", txt, QMessageBox.Ok, QMessageBox.Cancel ) if result == QMessageBox.Cancel: return # Import in separate thread thread = threading.Thread(target=self._import_tracks, args=(new_tracks,)) thread.start() def _import_tracks(self, tracks: list): """ Create track objects from passed files and add to visible playlist """ with Session() as session: for (fname, tags) in tracks: track = Tracks(session, fname) helpers.set_track_metadata(session, track) helpers.normalise_track(track.path) self.visible_playlist_tab().insert_track(session, track) self.visible_playlist_tab().save_playlist(session) def insert_header(self) -> None: """Show dialog box to enter header text and add to playlist""" try: playlist_tab = self.visible_playlist_tab() except AttributeError: # Just return if there's no visible playlist tab return # Get header text dlg: QInputDialog = QInputDialog(self) dlg.setInputMode(QInputDialog.TextInput) dlg.setLabelText("Header text:") dlg.resize(500, 100) ok = dlg.exec() if ok: with Session() as session: playlist_tab.insert_header(session, dlg.textValue()) playlist_tab.save_playlist(session) def insert_track(self) -> None: """Show dialog box to select and add track from database""" with Session() as session: dlg = DbDialog(self, session, get_one_track=False) dlg.exec() def load_last_playlists(self) -> None: """Load the playlists that were open when the last session closed""" with Session() as session: for playlist in Playlists.get_open(session): _ = self.create_playlist_tab(session, playlist) # Set active tab record = Settings.get_int_settings(session, "active_tab") if record and record.f_int is not None: self.tabPlaylist.setCurrentIndex(record.f_int) def move_playlist_rows(self, session: Session, playlistrows: List[PlaylistRows]) -> None: """ Move passed playlist rows to another playlist Actions required: - identify destination playlist - update playlist for the rows in the database - remove them from the display - update destination playlist display if loaded """ if not playlistrows: log.debug(f"musicmuster.move_playlist_rows({playlistrows=}") # Identify destination playlist visible_tab = self.visible_playlist_tab() source_playlist = visible_tab.playlist_id # Get destination playlist id playlists = [] for playlist in Playlists.get_all(session): if playlist.id == source_playlist: continue else: playlists.append(playlist) dlg = SelectPlaylistDialog(self, playlists=playlists, session=session) dlg.exec() if not dlg.playlist: return destination_playlist_id = dlg.playlist.id # Remove moved rows from display and save visible_tab.remove_rows([plr.row_number for plr in playlistrows]) visible_tab.save_playlist(session) # Update destination playlist in the database last_row = PlaylistRows.get_last_used_row(session, destination_playlist_id) if last_row is not None: next_row = last_row + 1 else: next_row = 0 for plr in playlistrows: plr.row_number = next_row plr.playlist_id = destination_playlist_id # Reset played as it's not been played on this playlist plr.played = False # Update destination playlist_tab if visible (if not visible, it # will be re-populated when it is opened) destination_playlist_tab = None for tab in range(self.tabPlaylist.count()): if self.tabPlaylist.widget(tab).playlist_id == dlg.playlist.id: destination_playlist_tab = self.tabPlaylist.widget(tab) break if destination_playlist_tab: destination_playlist_tab.populate_display(session, dlg.playlist.id) def move_selected(self) -> None: """ Move selected rows to another playlist """ with Session() as session: self.move_playlist_rows( session, self.visible_playlist_tab().get_selected_playlistrows(session) ) 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: """ Move unplayed rows to another playlist """ playlist_id = self.visible_playlist_tab().playlist_id with Session() as session: unplayed_playlist_rows = PlaylistRows.get_unplayed_rows( session, playlist_id) if helpers.ask_yes_no("Move tracks", f"Move {len(unplayed_playlist_rows)} tracks:" " Are you sure?" ): self.move_playlist_rows(session, unplayed_playlist_rows) def new_from_template(self) -> None: """Create new playlist from template""" with Session() as session: templates = Playlists.get_all_templates(session) dlg = SelectPlaylistDialog(self, playlists=templates, session=session) dlg.exec() template = dlg.playlist if template: playlist_name = self.solicit_playlist_name() if not playlist_name: return playlist = Playlists.create_playlist_from_template( session, template, playlist_name) tab_index = self.create_playlist_tab(session, playlist) playlist.mark_open(session, tab_index) def open_playlist(self): """Open existing playlist""" with Session() as session: playlists = Playlists.get_closed(session) dlg = SelectPlaylistDialog(self, playlists=playlists, session=session) dlg.exec() playlist = dlg.playlist if playlist: tab_index = self.create_playlist_tab(session, playlist) playlist.mark_open(session, tab_index) def paste_rows(self) -> None: """ Paste earlier cut rows. Process: - ensure we have some cut rows - if not pasting at end of playlist, move later rows down - update plrs with correct playlist and row - if moving between playlists: renumber source playlist rows - else: check integrity of playlist rows """ if not self.selected_plrs: return playlist_tab = self.visible_playlist_tab() dst_playlist_id = playlist_tab.playlist_id dst_row = self.visible_playlist_tab().get_new_row_number() with Session() as session: # Create space in destination playlist PlaylistRows.move_rows_down(session, dst_playlist_id, dst_row, len(self.selected_plrs)) session.commit() # Update plrs row = dst_row src_playlist_id = None for plr in self.selected_plrs: # Update moved rows session.add(plr) if not src_playlist_id: src_playlist_id = plr.playlist_id plr.playlist_id = dst_playlist_id plr.row_number = row row += 1 session.commit() # Update display self.visible_playlist_tab().populate_display( session, dst_playlist_id, scroll_to_top=False) # If source playlist is not destination playlist, fixup row # numbers and update display if src_playlist_id != dst_playlist_id: PlaylistRows.fixup_rownumbers(session, src_playlist_id) # Update source playlist_tab if visible (if not visible, it # will be re-populated when it is opened) source_playlist_tab = None for tab in range(self.tabPlaylist.count()): if self.tabPlaylist.widget(tab).playlist_id == \ src_playlist_id: source_playlist_tab = self.tabPlaylist.widget(tab) break if source_playlist_tab: source_playlist_tab.populate_display( session, src_playlist_id, scroll_to_top=False) # Reset so rows can't be repasted self.selected_plrs = None def play_next(self, position: Optional[float] = None) -> None: """ Play next track, optionally from passed position. Actions required: - If there is no next track set, return. - If there's currently a track playing, fade it. - Move next track to current track. - Ensure playlist tabs are the correct colour - Restore volume if -3dB active - Play (new) current track. - Tell database to record it as played - Tell playlist track is now playing - Note that track is now playing - Disable play next controls - Update headers - Update clocks """ # If there is no next track set, return. if not self.next_track: log.debug("musicmuster.play_next(): no next track selected") return with Session() as session: # If there's currently a track playing, fade it. self.stop_playing(fade=True) # Move next track to current track. self.current_track = self.next_track self.next_track = None # Ensure playlist tabs are the correct colour # If current track on different playlist_tab to last, reset # last track playlist_tab colour if self.current_track_playlist_tab != self.next_track_playlist_tab: self.set_tab_colour(self.current_track_playlist_tab, QColor(Config.COLOUR_NORMAL_TAB)) # # Update record of current track playlist_tab self.current_track_playlist_tab = self.next_track_playlist_tab self.next_track_playlist_tab = None # Set current track playlist_tab colour self.set_tab_colour(self.current_track_playlist_tab, QColor(Config.COLOUR_CURRENT_TAB)) # Restore volume if -3dB active if self.btnDrop3db.isChecked(): self.btnDrop3db.setChecked(False) # Play (new) current track start_at = datetime.now() self.music.play(self.current_track.path, position) # Tell database to record it as played Playdates(session, self.current_track.id) # Tell playlist track is now playing self.current_track_playlist_tab.play_started(session) # Note that track is now playing self.playing = True # Disable play next controls self.disable_play_next_controls() # Update headers self.update_headers() # Update clocks self.label_track_length.setText( helpers.ms_to_mmss(self.current_track.duration) ) fade_at = self.current_track.fade_at silence_at = self.current_track.silence_at self.label_fade_length.setText( helpers.ms_to_mmss(silence_at - fade_at)) self.label_start_time.setText( start_at.strftime(Config.TRACK_TIME_FORMAT)) self.current_track_end_time = start_at + timedelta( milliseconds=self.current_track.duration) self.label_end_time.setText( self.current_track_end_time.strftime(Config.TRACK_TIME_FORMAT)) def resume(self) -> None: """ Resume playing stopped track Actions required: - Return if no saved position - Store saved position - Store next track - Set previous track to be next track - Call play_next() from saved position - Reset next track """ # Return if no saved position if not self.previous_track_position: return # Note resume point resume_from = self.previous_track_position # Remember current next track original_next_track = self.next_track original_next_track_playlist_tab = self.next_track_playlist_tab with Session() as session: # Set last track to be next track self.this_is_the_next_track(session, self.previous_track_playlist_tab, self.previous_track) # Resume last track self.play_next(resume_from) # Reset next track if there was one if original_next_track: self.this_is_the_next_track(session, original_next_track_playlist_tab, original_next_track) def save_as_template(self) -> None: """Save current playlist as template""" with Session() as session: template_names = [ a.name for a in Playlists.get_all_templates(session) ] while True: # Get name for new template dlg = QInputDialog(self) dlg.setInputMode(QInputDialog.TextInput) dlg.setLabelText("Template name:") dlg.resize(500, 100) ok = dlg.exec() if not ok: return template_name = dlg.textValue() if template_name not in template_names: break helpers.show_warning("Duplicate template", "Template name already in use" ) Playlists.save_as_template(session, self.visible_playlist_tab().playlist_id, template_name) helpers.show_OK("Template", "Template saved") def search_playlist(self) -> None: """Show text box to search playlist""" # Disable play controls so that 'return' in search box doesn't # play next track self.disable_play_next_controls() self.txtSearch.setHidden(False) self.txtSearch.setFocus() # Select any text that may already be there self.txtSearch.selectAll() def search_playlist_clear(self) -> None: """Tidy up and reset search bar""" # Clear the search text self.visible_playlist_tab().set_search("") # Clean up search bar self.txtSearch.setText("") self.txtSearch.setHidden(True) def search_playlist_return(self) -> None: """Initiate search when return pressed""" self.visible_playlist_tab().set_search(self.txtSearch.text()) self.enable_play_next_controls() def select_next_row(self) -> None: """Select next or first row in playlist""" self.visible_playlist_tab().select_next_row() def select_previous_row(self) -> None: """Select previous or first row in playlist""" self.visible_playlist_tab().select_previous_row() def set_main_window_size(self) -> None: """Set size of window from database""" with Session() as session: record = Settings.get_int_settings(session, "mainwindow_x") x = record.f_int or 1 record = Settings.get_int_settings(session, "mainwindow_y") y = record.f_int or 1 record = Settings.get_int_settings(session, "mainwindow_width") width = record.f_int or 1599 record = Settings.get_int_settings(session, "mainwindow_height") height = record.f_int or 981 self.setGeometry(x, y, width, height) record = Settings.get_int_settings(session, "splitter_top") splitter_top = record.f_int or 256 record = Settings.get_int_settings(session, "splitter_bottom") splitter_bottom = record.f_int or 256 self.splitter.setSizes([splitter_top, splitter_bottom]) return def set_tab_colour(self, widget: PlaylistTab, colour: QColor) -> None: """ Find the tab containing the widget and set the text colour """ idx = self.tabPlaylist.indexOf(widget) self.tabPlaylist.tabBar().setTabTextColor(idx, colour) def show_current(self) -> None: """Scroll to show current track""" log.debug("KAE: musicmuster.show_current()") if self.current_track_playlist_tab != self.visible_playlist_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""" log.debug("KAE: musicmuster.show_next()") if self.next_track_playlist_tab != self.visible_playlist_tab(): self.tabPlaylist.setCurrentWidget(self.next_track_playlist_tab) self.tabPlaylist.currentWidget().scroll_next_to_top() def solicit_playlist_name(self) -> Optional[str]: """Get name of playlist from user""" dlg = QInputDialog(self) dlg.setInputMode(QInputDialog.TextInput) dlg.setLabelText("Playlist name:") dlg.resize(500, 100) ok = dlg.exec() if ok: return dlg.textValue() else: return None def stop(self) -> None: """Stop playing immediately""" self.stop_playing(fade=False) def stop_playing(self, fade=True) -> None: """ Stop playing current track Actions required: - Return if not playing - Stop/fade track - Reset playlist_tab colour - Run end-of-track actions """ # Return if not playing if not self.playing: return # Stop/fade track self.previous_track_position = self.music.get_position() if fade: self.music.fade() else: self.music.stop() # Reset playlist_tab colour 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() 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 this_is_the_next_track(self, session: Session, playlist_tab: PlaylistTab, track: Tracks) -> None: """ This is notification from a playlist tab that it holds the next track to be played. Actions required: - Clear next track if on other tab - Reset tab colour if on other tab - Note next playlist tab - Set next playlist_tab tab colour - Note next track - Update headers - Populate ‘info’ tabs """ # Clear next track if on another tab if self.next_track_playlist_tab != playlist_tab: # We need to reset the ex-next-track playlist if self.next_track_playlist_tab: self.next_track_playlist_tab.clear_next(session) # Reset tab colour if on other tab if (self.next_track_playlist_tab != self.current_track_playlist_tab): self.set_tab_colour( self.next_track_playlist_tab, QColor(Config.COLOUR_NORMAL_TAB)) # Note next playlist tab self.next_track_playlist_tab = playlist_tab # Set next playlist_tab tab colour if it isn't the # currently-playing tab if (self.next_track_playlist_tab != self.current_track_playlist_tab): self.set_tab_colour( self.next_track_playlist_tab, QColor(Config.COLOUR_NEXT_TAB)) # Note next track self.next_track = TrackData(track) # Populate footer if we're not currently playing if not self.playing and self.next_track: self.label_track_length.setText( helpers.ms_to_mmss(self.next_track.duration) ) self.label_fade_length.setText(helpers.ms_to_mmss( self.next_track.silence_at - self.next_track.fade_at)) # Update headers self.update_headers() # Populate 'info' tabs with Wikipedia info, but queue it because # it isn't quick track_title = track.title QTimer.singleShot( 1, lambda: self.tabInfolist.open_in_wikipedia(track_title) ) def tick(self) -> None: """ Carry out clock tick actions. The Time of Day clock and any cart progress bars are updated every tick (500ms). All other timers are updated every second. As the timer displays have a one-second resolution, updating every 500ms can result in some timers updating and then, 500ms later, other timers updating. That looks odd. Actions required: - Update TOD clock - Call cart_tick - If track is playing: update track clocks time and colours - Else: run stop_track """ # Update TOD clock self.lblTOD.setText(datetime.now().strftime(Config.TOD_TIME_FORMAT)) # Update carts self.cart_tick() self.even_tick = not self.even_tick if not self.even_tick: return if not self.playing: return # If track is playing, update track clocks time and colours if self.music.player and self.music.player.is_playing(): playtime = self.music.get_playtime() time_to_fade = (self.current_track.fade_at - playtime) time_to_silence = ( self.current_track.silence_at - playtime) time_to_end = (self.current_track.duration - playtime) # Elapsed time self.label_elapsed_timer.setText(helpers.ms_to_mmss(playtime)) # Time to fade self.label_fade_timer.setText(helpers.ms_to_mmss(time_to_fade)) # If silent in the next 5 seconds, put warning colour on # time to silence box and enable play controls if time_to_silence <= 5500: self.frame_silent.setStyleSheet( f"background: {Config.COLOUR_ENDING_TIMER}" ) self.enable_play_next_controls() # Set warning colour on time to silence box when fade starts elif time_to_fade <= 500: self.frame_silent.setStyleSheet( f"background: {Config.COLOUR_WARNING_TIMER}" ) # Five seconds before fade starts, set warning colour on # time to silence box and enable play controls elif time_to_fade <= 5500: self.frame_fade.setStyleSheet( f"background: {Config.COLOUR_WARNING_TIMER}" ) self.enable_play_next_controls() else: self.frame_silent.setStyleSheet("") self.frame_fade.setStyleSheet("") self.label_silent_timer.setText( helpers.ms_to_mmss(time_to_silence) ) # Time to end self.label_end_timer.setText(helpers.ms_to_mmss(time_to_end)) else: if self.playing: self.stop_playing() def update_current_track(self, track): """Update current track with passed details""" self.current_track = TrackData(track) self.update_headers() def update_next_track(self, track): """Update next track with passed details""" self.next_track = TrackData(track) self.update_headers() def update_headers(self) -> None: """ Update last / current / next track headers """ try: self.hdrPreviousTrack.setText( f"{self.previous_track.title} - {self.previous_track.artist}") except AttributeError: self.hdrPreviousTrack.setText("") try: self.hdrCurrentTrack.setText( f"{self.current_track.title} - {self.current_track.artist}") except AttributeError: self.hdrCurrentTrack.setText("") try: self.hdrNextTrack.setText( f"{self.next_track.title} - {self.next_track.artist}" ) except AttributeError: self.hdrNextTrack.setText("") class CartDialog(QDialog): """Edit cart details""" def __init__(self, parent: QMainWindow, session: Session, cart: Carts) -> None: """ Manage carts """ super().__init__(parent) self.parent = parent self.session = session self.ui = Ui_DialogCartEdit() self.ui.setupUi(self) self.path = cart.path self.ui.lblPath.setText(self.path) self.ui.lineEditName.setText(cart.name) self.ui.chkEnabled.setChecked(cart.enabled) self.ui.windowTitle = "Edit Cart " + str(cart.id) self.ui.btnFile.clicked.connect(self.choose_file) def choose_file(self) -> None: """File select""" dlg = QFileDialog() dlg.setFileMode(QFileDialog.ExistingFile) dlg.setViewMode(QFileDialog.Detail) dlg.setDirectory(Config.CART_DIRECTORY) dlg.setNameFilter("Music files (*.flac *.mp3)") if dlg.exec_(): self.path = dlg.selectedFiles()[0] self.ui.lblPath.setText(self.path) class DbDialog(QDialog): """Select track from database""" def __init__(self, parent: QMainWindow, session: Session, get_one_track: bool = False) -> None: """ Subclassed QDialog to manage track selection If get_one_track is True, return after first track selection with that track in ui.track. Otherwise, allow multiple tracks to be added to the playlist. """ super().__init__(parent) self.session = session self.get_one_track = get_one_track 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.double_click) 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.ui.track = None if get_one_track: self.ui.txtNote.hide() self.ui.lblNote.hide() 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 __del__(self) -> None: """Save dialog size and position""" 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()}) def add_selected(self) -> None: """Handle Add button""" if (not self.ui.matchList.selectedItems() and not self.ui.txtNote.text()): return track = None item = self.ui.matchList.currentItem() if item: track = item.data(Qt.UserRole) self.add_track(track) def add_selected_and_close(self) -> None: """Handle Add and Close button""" self.add_selected() self.accept() def add_track(self, track: Tracks) -> None: """Add passed track to playlist on screen""" if self.get_one_track: self.ui.track = track self.accept() return self.parent().visible_playlist_tab().insert_track( self.session, track, note=self.ui.txtNote.text()) # Save to database (which will also commit changes) self.parent().visible_playlist_tab().save_playlist(self.session) # Clear note field and select search text to make it easier for # next search self.ui.txtNote.clear() self.select_searchtext() def chars_typed(self, s: str) -> None: """Handle text typed in search box""" self.ui.matchList.clear() if len(s) > 1: 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 = Playdates.last_played(self.session, track.id) t = QListWidgetItem() t.setText( f"{track.title} - {track.artist} " f"[{helpers.ms_to_mmss(track.duration)}] " f"({helpers.get_relative_date(last_played)})" ) t.setData(Qt.UserRole, track) self.ui.matchList.addItem(t) def double_click(self, entry: QListWidgetItem) -> None: """Add items that are double-clicked""" track = entry.data(Qt.UserRole) self.add_track(track) # Select search text to make it easier for next search self.select_searchtext() 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.UserRole) self.ui.dbPath.setText(track.path) 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__(parent) self.ui = Ui_DateSelect() self.ui.setupUi(self) self.ui.dateTimeEdit.setDate(QDate.currentDate()) self.ui.dateTimeEdit.setTime(QTime(19, 59, 0)) self.ui.buttonBox.accepted.connect(self.accept) self.ui.buttonBox.rejected.connect(self.reject) class SelectPlaylistDialog(QDialog): def __init__(self, parent=None, playlists=None, session=None): super().__init__(parent) if playlists is None: return self.ui = Ui_dlgSelectPlaylist() self.ui.setupUi(self) self.ui.lstPlaylists.itemDoubleClicked.connect(self.list_doubleclick) self.ui.buttonBox.accepted.connect(self.open) self.ui.buttonBox.rejected.connect(self.close) self.session = session self.playlist = None record = Settings.get_int_settings( self.session, "select_playlist_dialog_width") width = record.f_int or 800 record = Settings.get_int_settings( self.session, "select_playlist_dialog_height") height = record.f_int or 600 self.resize(width, height) for playlist in playlists: p = QListWidgetItem() p.setText(playlist.name) p.setData(Qt.UserRole, playlist) self.ui.lstPlaylists.addItem(p) def __del__(self): # review record = Settings.get_int_settings( self.session, "select_playlist_dialog_height") if record.f_int != self.height(): record.update(self.session, {'f_int': self.height()}) record = Settings.get_int_settings( self.session, "select_playlist_dialog_width") if record.f_int != self.width(): record.update(self.session, {'f_int': self.width()}) def list_doubleclick(self, entry): # review self.playlist = entry.data(Qt.UserRole) self.accept() def open(self): # review if self.ui.lstPlaylists.selectedItems(): item = self.ui.lstPlaylists.currentItem() self.playlist = item.data(Qt.UserRole) self.accept() if __name__ == "__main__": """ If command line arguments given, carry out requested function and exit. Otherwise run full application. """ p = argparse.ArgumentParser() # Only allow at most one option to be specified group = p.add_mutually_exclusive_group() group.add_argument('-b', '--bitrates', action="store_true", dest="update_bitrates", default=False, help="Update bitrates in database") group.add_argument('-c', '--check-database', action="store_true", dest="check_db", default=False, help="Check and report on database") args = p.parse_args() # Run as required if args.check_db: log.debug("Updating database") with Session() as session: check_db(session) elif args.update_bitrates: log.debug("Update bitrates") with Session() as session: update_bitrates(session) else: # Normal run try: Base.metadata.create_all(engine) app = QApplication(sys.argv) win = Window() win.show() sys.exit(app.exec()) except Exception as exc: from helpers import send_mail msg = stackprinter.format(exc) send_mail(Config.ERRORS_TO, Config.ERRORS_FROM, "Exception from musicmuster", msg) print("\033[1;31;47mUnhandled exception starts") stackprinter.show(style="darkbg") print("Unhandled exception ends\033[1;37;40m")