#!/usr/bin/env python3 # Standard library imports from __future__ import annotations from functools import partial from slugify import slugify # type: ignore from typing import Any, Callable import argparse from dataclasses import dataclass, field import datetime as dt import os import subprocess import sys import urllib.parse import webbrowser import yaml # PyQt imports from PyQt6.QtCore import ( QDate, Qt, QTime, QTimer, QVariant, ) from PyQt6.QtGui import ( QAction, QCloseEvent, QColor, QFont, QIcon, QKeySequence, QPalette, QShortcut, ) from PyQt6.QtWidgets import ( QAbstractItemView, QApplication, QCheckBox, QComboBox, QDialog, QFileDialog, QHBoxLayout, QInputDialog, QLabel, QLineEdit, QListWidgetItem, QMainWindow, QMenu, QMessageBox, QPushButton, QSpinBox, QTableView, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, ) # Third party imports # import line_profiler from pygame import mixer import stackprinter # type: ignore # App imports from classes import ( ApplicationError, Filter, InsertTrack, MusicMusterSignals, PlaylistDTO, QueryDTO, SelectedRows, TrackInfo, ) from config import Config from dialogs import TrackInsertDialog from file_importer import FileImporter from helpers import file_is_unreadable, get_name from log import log, log_call from playlistmodel import PlaylistModel, PlaylistProxyModel from playlistrow import PlaylistRow, TrackSequence from playlists import PlaylistTab from querylistmodel import QuerylistModel from ui.dlg_SelectPlaylist_ui import Ui_dlgSelectPlaylist # type: ignore from ui.downloadcsv_ui import Ui_DateSelect # type: ignore from ui import icons_rc # noqa F401 from ui.main_window_footer_ui import Ui_FooterSection # type: ignore from ui.main_window_header_ui import Ui_HeaderSection # type: ignore from ui.main_window_playlist_ui import Ui_PlaylistSection # type: ignore from utilities import check_db, update_bitrates import ds import helpers class SignalMonitor: def __init__(self): self.signals = MusicMusterSignals() self.signals.enable_escape_signal.connect( partial(self.show_signal, "enable_escape_signal ") ) self.signals.resize_rows_signal.connect( partial(self.show_signal, "resize_rows_signal ") ) self.signals.signal_add_track_to_header.connect( partial(self.show_signal, "signal_add_track_to_header ") ) self.signals.signal_begin_insert_rows.connect( partial(self.show_signal, "signal_begin_insert_rows ") ) self.signals.signal_end_insert_rows.connect( partial(self.show_signal, "signal_end_insert_rows ") ) self.signals.signal_insert_track.connect( partial(self.show_signal, "signal_insert_track ") ) self.signals.signal_playlist_selected_rows.connect( partial(self.show_signal, "signal_playlist_selected_rows ") ) self.signals.signal_set_next_row.connect( partial(self.show_signal, "signal_set_next_row ") ) self.signals.signal_set_next_track.connect( partial(self.show_signal, "signal_set_next_track ") ) self.signals.signal_track_started.connect( partial(self.show_signal, "signal_track_started ") ) # span_cells_signal is very noisy # self.signals.span_cells_signal.connect( # partial(self.show_signal, "span_cells_signal ") # ) self.signals.status_message_signal.connect( partial(self.show_signal, "status_message_signal ") ) self.signals.signal_track_ended.connect( partial(self.show_signal, "signal_track_ended ") ) def show_signal(self, name: str, *args: Any) -> None: log.debug(f"{name=}, args={args}") @dataclass class Current: base_model: PlaylistModel | None = None proxy_model: PlaylistProxyModel | None = None playlist_id: int = 0 selected_row_numbers: list[int] = field(default_factory=list) class DownloadCSV(QDialog): def __init__(self, parent=None): super().__init__() 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 EditDeleteDialog(QDialog): def __init__(self, templates: list[tuple[str, int]]) -> None: super().__init__() self.templates = templates self.selection: tuple[str, int] = ("", -1) self.init_ui() def init_ui(self) -> None: # Create label label = QLabel("Select template:") # Create combo box self.combo_box = QComboBox() for text, id_ in self.templates: self.combo_box.addItem(text, id_) # Create buttons edit_button = QPushButton("Edit") delete_button = QPushButton("Delete") cancel_button = QPushButton("Cancel") # Connect buttons edit_button.clicked.connect(self.edit_clicked) delete_button.clicked.connect(self.delete_clicked) cancel_button.clicked.connect(self.cancel_clicked) # Layout setup top_layout = QHBoxLayout() top_layout.addWidget(label) top_layout.addWidget(self.combo_box) bottom_layout = QHBoxLayout() bottom_layout.addStretch() bottom_layout.addWidget(edit_button) bottom_layout.addWidget(delete_button) bottom_layout.addWidget(cancel_button) main_layout = QVBoxLayout() main_layout.addLayout(top_layout) main_layout.addLayout(bottom_layout) self.setLayout(main_layout) self.setWindowTitle("Edit or Delete Template") def edit_clicked(self) -> None: self.selection = ("Edit", self.combo_box.currentData()) self.accept() def delete_clicked(self) -> None: self.selection = ("Delete", self.combo_box.currentData()) self.accept() def cancel_clicked(self) -> None: self.selection = ("Cancelled", -1) self.reject() class FilterDialog(QDialog): def __init__(self, name: str, filter: Filter) -> None: super().__init__() self.filter = filter self.setWindowTitle("Filter Settings") layout = QVBoxLayout() # Name row name_layout = QHBoxLayout() name_label = QLabel("Name") self.name_text = QLineEdit() self.name_text.setText(name) name_layout.addWidget(name_label) name_layout.addWidget(self.name_text) layout.addLayout(name_layout) # Path row path_layout = QHBoxLayout() path_label = QLabel("Path") self.path_combo = QComboBox() self.path_combo.addItems( [Config.FILTER_PATH_CONTAINS, Config.FILTER_PATH_EXCLUDING] ) for idx in range(self.path_combo.count()): if self.path_combo.itemText(idx) == filter.path_type: self.path_combo.setCurrentIndex(idx) break self.path_text = QLineEdit() if filter.path: self.path_text.setText(filter.path) path_layout.addWidget(path_label) path_layout.addWidget(self.path_combo) path_layout.addWidget(self.path_text) layout.addLayout(path_layout) # Last played row last_played_layout = QHBoxLayout() last_played_label = QLabel("Last played") self.last_played_combo = QComboBox() self.last_played_combo.addItems( [ Config.FILTER_PLAYED_COMPARATOR_BEFORE, Config.FILTER_PLAYED_COMPARATOR_NEVER, Config.FILTER_PLAYED_COMPARATOR_ANYTIME, ] ) for idx in range(self.last_played_combo.count()): if self.last_played_combo.itemText(idx) == filter.last_played_comparator: self.last_played_combo.setCurrentIndex(idx) break self.last_played_spinbox = QSpinBox() self.last_played_spinbox.setMinimum(0) self.last_played_spinbox.setMaximum(100) self.last_played_spinbox.setValue(filter.last_played_number or 0) self.last_played_unit = QComboBox() self.last_played_unit.addItems( [ Config.FILTER_PLAYED_YEARS, Config.FILTER_PLAYED_MONTHS, Config.FILTER_PLAYED_WEEKS, Config.FILTER_PLAYED_DAYS, ] ) for idx in range(self.last_played_unit.count()): if self.last_played_unit.itemText(idx) == filter.last_played_unit: self.last_played_unit.setCurrentIndex(idx) break last_played_ago_label = QLabel("ago") last_played_layout.addWidget(last_played_label) last_played_layout.addWidget(self.last_played_combo) last_played_layout.addWidget(self.last_played_spinbox) last_played_layout.addWidget(self.last_played_unit) last_played_layout.addWidget(last_played_ago_label) layout.addLayout(last_played_layout) # Duration row duration_layout = QHBoxLayout() duration_label = QLabel("Duration") self.duration_combo = QComboBox() self.duration_combo.addItems( [Config.FILTER_DURATION_LONGER, Config.FILTER_DURATION_SHORTER] ) for idx in range(self.duration_combo.count()): if self.duration_combo.itemText(idx) == filter.duration_type: self.duration_combo.setCurrentIndex(idx) break self.duration_spinbox = QSpinBox() self.duration_spinbox.setMinimum(0) self.duration_spinbox.setMaximum(1000) self.duration_spinbox.setValue(filter.duration_number) self.duration_unit = QComboBox() self.duration_unit.addItems( [Config.FILTER_DURATION_MINUTES, Config.FILTER_DURATION_SECONDS] ) self.duration_unit.setCurrentText(Config.FILTER_DURATION_MINUTES) for idx in range(self.duration_unit.count()): if self.duration_unit.itemText(idx) == filter.duration_unit: self.duration_unit.setCurrentIndex(idx) break duration_layout.addWidget(duration_label) duration_layout.addWidget(self.duration_combo) duration_layout.addWidget(self.duration_spinbox) duration_layout.addWidget(self.duration_unit) layout.addLayout(duration_layout) # Buttons button_layout = QHBoxLayout() self.ok_button = QPushButton("OK") self.cancel_button = QPushButton("Cancel") self.cancel_button.clicked.connect(self.reject) self.ok_button.clicked.connect(self.ok_clicked) button_layout.addWidget(self.ok_button) button_layout.addWidget(self.cancel_button) layout.addLayout(button_layout) self.setLayout(layout) # Connect signals self.last_played_combo.currentIndexChanged.connect( self.toggle_last_played_controls ) self.toggle_last_played_controls() def toggle_last_played_controls(self): disabled = self.last_played_combo.currentText() == "never" self.last_played_spinbox.setDisabled(disabled) self.last_played_unit.setDisabled(disabled) def ok_clicked(self) -> None: """ Update filter to match selections """ self.filter.path_type = self.path_combo.currentText() self.filter.path = self.path_text.text() self.filter.last_played_number = self.last_played_spinbox.value() self.filter.last_played_comparator = self.last_played_combo.currentText() self.filter.last_played_unit = self.last_played_unit.currentText() self.filter.duration_type = self.duration_combo.currentText() self.filter.duration_number = self.duration_spinbox.value() self.filter.duration_unit = self.duration_unit.currentText() self.accept() @dataclass class ItemlistItem: id: int name: str favourite: bool = False class ItemlistManager(QDialog): def __init__(self) -> None: super().__init__() self.setWindowTitle("Item Manager") self.setMinimumSize(600, 400) layout = QVBoxLayout(self) self.table = QTableWidget(self) self.table.setColumnCount(2) self.table.setHorizontalHeaderLabels(["Item", "Actions"]) hh = self.table.horizontalHeader() if not hh: raise ApplicationError("ItemlistManager failed to create horizontalHeader") hh.setStretchLastSection(True) self.table.setColumnWidth(0, 288) self.table.setColumnWidth(1, 300) layout.addWidget(self.table) button_layout = QHBoxLayout() self.new_button = QPushButton("New") self.new_button.clicked.connect(self.new_item) button_layout.addWidget(self.new_button) self.close_button = QPushButton("Close") self.close_button.clicked.connect(self.close) button_layout.addWidget(self.close_button) layout.addLayout(button_layout) def populate_table(self, items: list[ItemlistItem]) -> None: """Populates the table with items and action buttons.""" self.items = items self.table.setRowCount(len(self.items)) for row, item in enumerate(self.items): item_text = QTableWidgetItem(item.name) if item.favourite: item_text.setFont(QFont("Arial", weight=QFont.Weight.Bold)) self.table.setItem(row, 0, item_text) # Action Buttons and Checkbox in a widget widget = QWidget() h_layout = QHBoxLayout(widget) h_layout.setContentsMargins(0, 0, 0, 0) h_layout.setSpacing(5) rename_button = QPushButton("Rename") rename_button.clicked.connect(lambda _, i=item.id: self.rename_item(i)) h_layout.addWidget(rename_button) edit_button = QPushButton("Edit") edit_button.clicked.connect(lambda _, i=item.id: self.edit_item(i)) h_layout.addWidget(edit_button) delete_button = QPushButton("Delete") delete_button.clicked.connect(lambda _, i=item.id: self.delete_item(i)) h_layout.addWidget(delete_button) fav_checkbox = QCheckBox() fav_checkbox.setChecked(item.favourite) fav_checkbox.stateChanged.connect( lambda state, cb=fav_checkbox, i=item.id: self.toggle_favourite( i, cb.isChecked() ) ) h_layout.addWidget(fav_checkbox) self.table.setCellWidget(row, 1, widget) def delete_item(self, item_id: int) -> None: """Subclass must implement this method""" raise NotImplementedError def edit_item(self, item_id: int) -> None: """Subclass must implement this method""" raise NotImplementedError def new_item(self) -> None: """Subclass must implement this method""" raise NotImplementedError def rename_item(self, item_id: int) -> None: """Subclass must implement this method""" raise NotImplementedError def change_text(self, item_id: int, new_text: str) -> None: """ Update text for one row """ for row in range(self.table.rowCount()): item = self.table.item(row, 0) if item and self.items[row].id == item_id: item.setText(new_text) self.items[row].name = new_text break def toggle_favourite(self, item_id: int, checked: bool) -> None: """Subclass must udpate database if required""" for row in range(self.table.rowCount()): item = self.table.item(row, 0) if item and self.items[row].id == item_id: font = QFont( "Arial", weight=QFont.Weight.Bold if checked else QFont.Weight.Normal, ) item.setFont(font) self.items[row].favourite = checked break class ManageQueries(ItemlistManager): """ Delete / edit queries """ def __init__(self, musicmuster: Window) -> None: super().__init__() self.musicmuster = musicmuster self.refresh_table() self.exec() def refresh_table(self) -> None: """ Update table in widget """ # Build a list of queries query_list: list[ItemlistItem] = [] for query in ds.queries_all(): query_list.append( ItemlistItem( name=query.name, id=query.query_id, favourite=query.favourite ) ) self.populate_table(query_list) # @log_call def delete_item(self, query_id: int) -> None: """delete query""" query = ds.query_by_id(query_id) if not query: raise ApplicationError( f"manage_template.delete({query_id=}) can't load query" ) if helpers.ask_yes_no( "Delete query", f"Delete query '{query.name}': " "Are you sure?", ): ds.query_delete(query_id) self.refresh_table() def _edit_item(self, query: QueryDTO) -> None: """Edit query""" dlg = FilterDialog(query.name, query.filter) if dlg.exec(): ds.query_update_filter (query.query_id, dlg.filter) ds.query_update_name(query.query_id, dlg.name_text.text()) def edit_item(self, query_id: int) -> None: """Edit query""" query = ds.query_by_id(query_id) if not query: raise ApplicationError( f"manage_template.edit_item({query_id=}) can't load query" ) return self._edit_item(query) def toggle_favourite(self, query_id: int, favourite: bool) -> None: """Mark query as (not) favourite""" ds.query_update_favourite(query_id, favourite) def new_item(self) -> None: """Create new query""" query_name = get_name(prompt="New query name:") if not query_name: return query = ds.query_create(query_name, Filter()) self._edit_item(query) self.refresh_table() def rename_item(self, query_id: int) -> None: """rename query""" query = ds.query_by_id(query_id) if not query: raise ApplicationError(f"Can't load query ({query_id=})") new_name = get_name(prompt="New query name", default=query.name) if not new_name: return ds.query_update_name(query_id, new_name) self.change_text(query_id, new_name) class ManageTemplates(ItemlistManager): """ Delete / edit templates """ def __init__(self, musicmuster: Window) -> None: super().__init__() self.musicmuster = musicmuster self.refresh_table() self.exec() def refresh_table(self) -> None: """ Update table in widget """ # Build a list of templates template_list: list[ItemlistItem] = [] for template in ds.playlists_templates_all(): template_list.append( ItemlistItem( name=template.name, id=template.playlist_id, favourite=template.favourite, ) ) self.populate_table(template_list) # @log_call def delete_item(self, template_id: int) -> None: """delete template""" template = ds.playlists_template_by_id(template_id) if not template: raise ApplicationError( f"manage_template.delete({template_id=}) can't load template" ) if helpers.ask_yes_no( "Delete template", f"Delete template '{template.name}': Are you sure?", ): # If template is currently open, re-check open_idx = self.musicmuster.get_tab_index_for_playlist(template_id) if open_idx: if not helpers.ask_yes_no( "Delete open template", f"Template '{template.name}' is currently open. Really delete?", ): return else: self.musicmuster.playlist_section.tabPlaylist.removeTab(open_idx) ds.playlist_delete(template.playlist_id) def edit_item(self, template_id: int) -> None: """Edit template""" template = ds.playlists_template_by_id(template_id) if not template: raise ApplicationError( f"manage_template.edit({template_id=}) can't load template" ) # Simply load the template as a playlist. Any changes # made will persist self.musicmuster._open_playlist(template, is_template=True) def toggle_favourite(self, template_id: int, favourite: bool) -> None: """Mark template as (not) favourite""" ds.playlist_update_template_favourite(template_id, favourite) def new_item( self, ) -> None: """Create new template""" # Get base template template_id = self.musicmuster.solicit_template_to_use( template_prompt="New template based upon:" ) if template_id is None: return # Get new template name name = self.musicmuster.get_playlist_name( default="", prompt="New template name:" ) if not name: return # Create playlist for template and mark is as a template template = ds.playlist_create(name, template_id, as_template=True) # Open it for editing self.musicmuster._open_playlist(template, is_template=True) def rename_item(self, template_id: int) -> None: """rename template""" template = ds.playlist_by_id(template_id) if not template: raise ApplicationError( f"manage_template.delete({template_id=}) can't load template" ) new_name = self.musicmuster.get_playlist_name(template.name) if new_name: ds.playlist_rename(template_id, new_name) class PreviewManager: """ Manage track preview player """ def __init__(self) -> None: mixer.init() self.intro: int | None = None self.path: str = "" self.row_number: int | None = None self.start_time: dt.datetime | None = None self.track_id: int = 0 def back(self, ms: int) -> None: """ Move play position back by 'ms' milliseconds """ position = max(0, (self.get_playtime() - ms)) / 1000 mixer.music.set_pos(position) self.start_time = dt.datetime.now() - dt.timedelta(seconds=position) def forward(self, ms: int) -> None: """ Move play position forward by 'ms' milliseconds """ position = (self.get_playtime() + ms) / 1000 mixer.music.set_pos(position) self.start_time = dt.datetime.now() - dt.timedelta(seconds=position) def get_playtime(self) -> int: """ Return time since track started in milliseconds, 0 if not playing """ if not mixer.music.get_busy(): return 0 if not self.start_time: return 0 return int((dt.datetime.now() - self.start_time).total_seconds() * 1000) def is_playing(self) -> bool: return mixer.music.get_busy() def move_to_intro_end(self) -> None: """ Move play position to 'buffer' milliseconds before end of intro. If no intro defined, do nothing. """ if self.intro is None: return position = max(0, self.intro - Config.PREVIEW_END_BUFFER_MS) / 1000 mixer.music.set_pos(position) self.start_time = dt.datetime.now() - dt.timedelta(seconds=position) def play(self) -> None: mixer.music.play() self.start_time = dt.datetime.now() def restart(self) -> None: """ Restart player from beginning """ if not mixer.music.get_busy(): return mixer.music.rewind() self.start_time = dt.datetime.now() def set_intro(self, ms: int) -> None: """ Set intro time """ self.intro = ms def set_track_info(self, track_id: int, track_intro: int, track_path: str) -> None: self.track_id = track_id self.intro = track_intro self.path = track_path # Check file readable if file_is_unreadable(self.path): raise ValueError(f"PreviewManager.__init__: {track_path=} unreadable") mixer.music.load(self.path) def stop(self) -> None: mixer.music.stop() mixer.music.unload() self.path = "" self.track_id = 0 self.start_time = None class QueryDialog(QDialog): """Dialog box to handle selecting track from a query""" def __init__(self, playlist_id: int, default: int = 0) -> None: super().__init__() self.playlist_id = playlist_id self.default = default self.signals = MusicMusterSignals() # Build a list of (query-name, playlist-id) tuples self.selected_tracks: list[int] = [] self.query_list: list[tuple[str, int]] = [] self.query_list.append((Config.NO_QUERY_NAME, 0)) for query in ds.queries_all(): self.query_list.append((query.name, query.query_id)) self.setWindowTitle("Query Selector") # Create label query_label = QLabel("Query:") # Top layout (Query label, combo box, and info label) top_layout = QHBoxLayout() # Query label query_label = QLabel("Query:") top_layout.addWidget(query_label) # Combo Box with fixed width self.combo_box = QComboBox() # self.combo_box.setFixedWidth(150) # Adjust as necessary for 20 characters for text, id_ in self.query_list: self.combo_box.addItem(text, id_) top_layout.addWidget(self.combo_box) # Table (middle part) self.table_view = QTableView() self.table_view.setSelectionMode( QAbstractItemView.SelectionMode.ExtendedSelection ) self.table_view.setSelectionBehavior( QAbstractItemView.SelectionBehavior.SelectRows ) self.table_view.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) self.table_view.setAlternatingRowColors(True) self.table_view.setVerticalScrollMode( QAbstractItemView.ScrollMode.ScrollPerPixel ) self.table_view.clicked.connect(self.handle_row_click) # Bottom layout (buttons) bottom_layout = QHBoxLayout() bottom_layout.addStretch() # Push buttons to the right self.add_tracks_button = QPushButton("Add tracks") self.add_tracks_button.setEnabled(False) # Disabled by default self.add_tracks_button.clicked.connect(self.add_tracks_clicked) bottom_layout.addWidget(self.add_tracks_button) self.cancel_button = QPushButton("Cancel") self.cancel_button.clicked.connect(self.cancel_clicked) bottom_layout.addWidget(self.cancel_button) # Main layout main_layout = QVBoxLayout() main_layout.addLayout(top_layout) main_layout.addWidget(self.table_view) main_layout.addLayout(bottom_layout) self.combo_box.currentIndexChanged.connect(self.query_changed) if self.default: default_idx = self.combo_box.findData(QVariant(self.default)) self.combo_box.setCurrentIndex(default_idx) self.path_text = QLineEdit() self.setLayout(main_layout) # Stretch last column *after* setting column widths which is # *much* faster h_header = self.table_view.horizontalHeader() if h_header: h_header.sectionResized.connect(self._column_resize) h_header.setStretchLastSection(True) # Resize on vertical header click v_header = self.table_view.verticalHeader() if v_header: v_header.setMinimumSectionSize(5) v_header.sectionHandleDoubleClicked.disconnect() v_header.sectionHandleDoubleClicked.connect( self.table_view.resizeRowToContents ) self.set_window_size() self.resizeRowsToContents() def add_tracks_clicked(self): self.selected_tracks = self.table_view.model().get_selected_track_ids() # new_row_number = self.current_row_or_end() # base_model = self.current.base_model for track_id in self.selected_tracks: insert_track_data = InsertTrack(self.playlist_id, track_id, note="") self.signals.signal_insert_track.emit(insert_track_data) self.accept() def cancel_clicked(self): self.selected_tracks = [] self.reject() def closeEvent(self, event: QCloseEvent | None) -> None: """ Record size and columns """ self.save_sizes() super().closeEvent(event) def accept(self) -> None: self.save_sizes() super().accept() def reject(self) -> None: self.save_sizes() super().reject() def save_sizes(self) -> None: """ Save window size """ # Save dialog box attributes attributes_to_save = dict( querylist_height=self.height(), querylist_width=self.width(), querylist_x=self.x(), querylist_y=self.y(), ) for name, value in attributes_to_save.items(): ds.setting_set(name, value) header = self.table_view.horizontalHeader() if header is None: return column_count = header.count() if column_count < 2: return for column_number in range(column_count - 1): attr_name = f"querylist_col_{column_number}_width" ds.setting_set(attr_name, self.table_view.columnWidth(column_number)) def _column_resize(self, column_number: int, _old: int, _new: int) -> None: """ Called when column width changes. """ header = self.table_view.horizontalHeader() if not header: return # Resize rows if necessary self.resizeRowsToContents() def resizeRowsToContents(self): header = self.table_view.verticalHeader() model = self.table_view.model() if model: for row in range(model.rowCount()): hint = self.table_view.sizeHintForRow(row) header.resizeSection(row, hint) def query_changed(self, idx: int) -> None: """ Called when user selects query """ # Get query query_id = self.combo_box.currentData() query = ds.query_by_id(query_id) if not query: return # Create model base_model = QuerylistModel(query.filter) # Create table self.table_view.setModel(base_model) self.set_column_sizes() def handle_row_click(self, index): self.table_view.model().toggle_row_selection(index.row()) self.table_view.clearSelection() # Enable 'Add tracks' button only when a row is selected selected = self.table_view.model().get_selected_track_ids() self.add_tracks_button.setEnabled(selected != []) def set_window_size(self) -> None: """Set window sizes""" x = ds.setting_get("querylist_x") or 100 y = ds.setting_get("querylist_y") or 100 width = ds.setting_get("querylist_width") or 100 height = ds.setting_get("querylist_height") or 100 self.setGeometry(x, y, width, height) def set_column_sizes(self) -> None: """Set column sizes""" header = self.table_view.horizontalHeader() if header is None: return column_count = header.count() if column_count < 2: return # Last column is set to stretch so ignore it here for column_number in range(column_count - 1): attr_name = f"querylist_col_{column_number}_width" width = ds.setting_get(attr_name) or Config.DEFAULT_COLUMN_WIDTH self.table_view.setColumnWidth(column_number, width) class SelectPlaylistDialog(QDialog): def __init__(self, parent=None, playlists=None): super().__init__() 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.playlist = None width = ds.setting_get("select_playlist_dialog_width") or 800 height = ds.setting_get("select_playlist_dialog_height") or 800 self.resize(width, height) for playlist in playlists: p = QListWidgetItem() p.setText(playlist.name) p.setData(Qt.ItemDataRole.UserRole, playlist) self.ui.lstPlaylists.addItem(p) def __del__(self): # review ds.setting_set("select_playlist_dialog_height", self.height()) ds.setting_set("select_playlist_dialog_width", self.width()) def list_doubleclick(self, entry): # review self.playlist = entry.data(Qt.ItemDataRole.UserRole) self.accept() def open(self): # review if self.ui.lstPlaylists.selectedItems(): item = self.ui.lstPlaylists.currentItem() self.playlist = item.data(Qt.ItemDataRole.UserRole) self.accept() @dataclass class MoveSource: model: PlaylistModel rows: list[int] class TemplateSelectorDialog(QDialog): """ Class to manage user selection of template """ def __init__( self, templates: list[tuple[str, int]], template_prompt: str | None ) -> None: super().__init__() self.templates = templates self.template_prompt = template_prompt self.selected_id = None self.init_ui() def init_ui(self): # Create label if not self.template_prompt: self.template_prompt = "Select template:" label = QLabel(self.template_prompt) # Create combo box self.combo_box = QComboBox() for text, id_ in self.templates: self.combo_box.addItem(text, id_) # Create buttons ok_button = QPushButton("OK") cancel_button = QPushButton("Cancel") # Connect buttons ok_button.clicked.connect(self.ok_clicked) cancel_button.clicked.connect(self.cancel_clicked) # Layout setup top_layout = QHBoxLayout() top_layout.addWidget(label) top_layout.addWidget(self.combo_box) bottom_layout = QHBoxLayout() bottom_layout.addStretch() bottom_layout.addWidget(ok_button) bottom_layout.addWidget(cancel_button) main_layout = QVBoxLayout() main_layout.addLayout(top_layout) main_layout.addLayout(bottom_layout) self.setLayout(main_layout) self.setWindowTitle("Template Selector") def ok_clicked(self): self.selected_id = self.combo_box.currentData() self.accept() def cancel_clicked(self): self.selected_id = -1 self.reject() # Per-section UI files class HeaderSection(QWidget, Ui_HeaderSection): def __init__(self, parent=None): super().__init__(parent) self.setupUi(self) class PlaylistSection(QWidget, Ui_PlaylistSection): def __init__(self, parent=None): super().__init__(parent) self.setupUi(self) class FooterSection(QWidget, Ui_FooterSection): def __init__(self, parent=None): super().__init__(parent) self.setupUi(self) class Window(QMainWindow): def __init__( self, parent: QWidget | None = None, *args: list, **kwargs: dict ) -> None: super().__init__(parent) # Build main window from per-section classes defined above central_widget = QWidget(self) self.setCentralWidget(central_widget) layout = QVBoxLayout(central_widget) self.header_section = HeaderSection() self.playlist_section = PlaylistSection() self.footer_section = FooterSection() layout.addWidget(self.header_section) layout.addWidget(self.playlist_section) layout.addWidget(self.footer_section) self.footer_section.widgetFadeVolume.hideAxis("bottom") self.footer_section.widgetFadeVolume.hideAxis("left") self.footer_section.widgetFadeVolume.setDefaultPadding(0) self.footer_section.widgetFadeVolume.setBackground(Config.FADE_CURVE_BACKGROUND) self.setWindowTitle(Config.MAIN_WINDOW_TITLE) # Add menu bar self.create_menu_bar() # Configure main window self.set_main_window_size() self.lblSumPlaytime = QLabel("") self.statusbar = self.statusBar() if not self.statusbar: raise ApplicationError("Can't create status bar") self.statusbar.addPermanentWidget(self.lblSumPlaytime) self.txtSearch = QLineEdit() self.txtSearch.setHidden(True) self.statusbar.addWidget(self.txtSearch) self.hide_played_tracks = False # Timers self.timer10: QTimer = QTimer() self.timer100: QTimer = QTimer() self.timer500: QTimer = QTimer() self.timer1000: QTimer = QTimer() self.timer10.start(10) self.timer100.start(100) self.timer500.start(500) self.timer1000.start(1000) # Misc self.preview_manager = PreviewManager() self.move_source: MoveSource | None = None self.disable_selection_timing = False self.catch_return_key = False self.importer: FileImporter | None = None self.current = Current() self.track_sequence = TrackSequence() self.signals = MusicMusterSignals() self.connect_signals_slots() webbrowser.register( "browser", None, webbrowser.BackgroundBrowser(Config.EXTERNAL_BROWSER_PATH), ) # Set up shortcut key for instant logging from keyboard self.action_quicklog = QShortcut(QKeySequence("Ctrl+L"), self) self.action_quicklog.activated.connect(self.quicklog) # Optionally print signals self.signal_monitor = SignalMonitor() # Load playlists self.load_last_playlists() # # # # # # # # # # Overrides # # # # # # # # # # def closeEvent(self, event: QCloseEvent | None) -> None: """Handle attempt to close main window""" if not event: return # Don't allow window to close when a track is playing if self.track_sequence.current and self.track_sequence.current.music.is_playing(): event.ignore() helpers.show_warning( self, "Track playing", "Can't close application while track is playing" ) else: # Save tab number of open playlists playlist_id_to_tab: dict[int, int] = {} for idx in range(self.playlist_section.tabPlaylist.count()): playlist_id_to_tab[ self.playlist_section.tabPlaylist.widget(idx).playlist_id ] = idx ds.playlist_save_tabs(playlist_id_to_tab) # Save window attributes attributes_to_save = dict( mainwindow_height=self.height(), mainwindow_width=self.width(), mainwindow_x=self.x(), mainwindow_y=self.y(), active_index=self.playlist_section.tabPlaylist.currentIndex(), ) for name, value in attributes_to_save.items(): ds.setting_set(name, value) event.accept() # # # # # # # # # # Internal utility functions # # # # # # # # # # def _active_tab(self) -> PlaylistTab: return self.playlist_section.tabPlaylist.currentWidget() # # # # # # # # # # Menu functions # # # # # # # # # # def create_action( self, text: str, handler: Callable, shortcut: str | None = None ) -> QAction: """ Helper function for menu creation. Create an action, bind it to a method, and set a shortcut if provided. """ action = QAction(text, self) action.triggered.connect(handler) if shortcut: action.setShortcut(shortcut) # Adding the shortcut return action def create_menu_bar(self): """Dynamically creates the menu bar from a YAML file.""" menu_bar = self.menuBar() # Load menu structure from YAML file with open("app/menu.yaml", "r") as file: menu_data = yaml.safe_load(file) self.menu_actions = {} # Store reference for enabling/disabling actions self.dynamic_submenus = {} # Store submenus for dynamic population for menu_item in menu_data["menus"]: menu = menu_bar.addMenu(menu_item["title"]) for action_item in menu_item["actions"]: if "separator" in action_item and action_item["separator"]: menu.addSeparator() continue # Check whether this is a submenu first if action_item.get("submenu"): submenu = QMenu(action_item["text"], self) menu.addMenu(submenu) # Store submenu reference for dynamic population self.dynamic_submenus[action_item["handler"]] = submenu submenu.aboutToShow.connect(self.populate_dynamic_submenu) continue # Skip the rest of the loop (no handler needed) # Now check for a normal menu action handler = getattr(self, action_item["handler"], None) if handler is None: print(f"Warning: No handler found for {action_item['text']}") continue action = self.create_action( action_item["text"], handler, action_item.get("shortcut") ) # Store reference to "Clear Selection" so we can enable/disable it if action_item.get("store_reference"): self.menu_actions[action_item["handler"]] = action menu.addAction(action) def get_new_playlist_dynamic_submenu_items( self, ) -> list[dict[str, str | int | bool]]: """ Return dynamically generated menu items, in this case templates marked as favourite from which to generate a new playlist. The handler is to call create_playlist_from_template with a dict of arguments. """ submenu_items: list[dict[str, str | int | bool]] = [ { "text": "Show all", "handler": "create_playlist_from_template", "args": 0, }, { "separator": True, }, ] templates = ds.playlists_templates_all() for template in templates: submenu_items.append( { "text": template.name, "handler": "create_playlist_from_template", "args": template.playlist_id, } ) return submenu_items def get_query_dynamic_submenu_items( self, ) -> list[dict[str, str | int | bool]]: """ Return dynamically generated menu items, in this case templates marked as favourite from which to generate a new playlist. The handler is to call show_query with a query_id. """ submenu_items: list[dict[str, str | int | bool]] = [ { "text": "Show all", "handler": "show_query", "args": 0, }, { "separator": True, }, ] queries = ds.queries_all(favourites_only=True) for query in queries: submenu_items.append( { "text": query.name, "handler": "show_query", "args": query.query_id, } ) return submenu_items def populate_dynamic_submenu(self): """Dynamically populates submenus when they are selected.""" submenu = self.sender() # Get the submenu that triggered the event # Find which submenu it is for key, stored_submenu in self.dynamic_submenus.items(): if submenu == stored_submenu: submenu.clear() # Dynamically call the correct function items = getattr(self, f"get_{key}_items")() for item in items: # Check for separator if "separator" in item and item["separator"]: submenu.addSeparator() continue action = QAction(item["text"], self) # Extract handler and arguments handler = getattr(self, item["handler"], None) args = item.get("args", ()) if handler: # Use a lambda to pass arguments to the function action.triggered.connect(lambda _, h=handler, a=args: h(a)) submenu.addAction(action) break # # # # # # # # # # Playlist management functions # # # # # # # # # # # @log_call def _create_playlist(self, name: str, template_id: int) -> PlaylistDTO: """ Create a playlist in the database, populate it from the template if template_id > 0, and return the PlaylistDTO object. """ return ds.playlist_create(name, template_id) # @log_call def _open_playlist(self, playlist: PlaylistDTO, is_template: bool = False) -> int: """ With passed playlist: - create models - create tab - switch to tab - mark playist as open return: tab index """ # Create base model and proxy model base_model = PlaylistModel(playlist.playlist_id, is_template) proxy_model = PlaylistProxyModel() proxy_model.setSourceModel(base_model) # Create tab playlist_tab = PlaylistTab(musicmuster=self, model=proxy_model) idx = self.playlist_section.tabPlaylist.addTab(playlist_tab, playlist.name) # Mark playlist as open ds.playlist_mark_status(playlist.playlist_id, open=True) # Switch to new tab self.playlist_section.tabPlaylist.setCurrentIndex(idx) self.update_playlist_icons() return idx # @log_call def create_playlist_from_template(self, template_id: int) -> None: """ Prompt for new playlist name and create from passed template_id. """ if template_id == 0: # Show all templates selected_template_id = self.solicit_template_to_use() if selected_template_id is None: return template_id = selected_template_id playlist_name = self.get_playlist_name() if not playlist_name: return _ = ds.playlist_create(playlist_name, template_id) # @log_call def delete_playlist(self, checked: bool = False) -> None: """ Delete current playlist. checked paramater passed by menu system but unused. """ playlist = ds.playlist_by_id(self.current.playlist_id) if playlist: if helpers.ask_yes_no( "Delete playlist", f"Delete playlist '{playlist.name}': " "Are you sure?", ): if self.close_playlist_tab(): ds.playlist_delete(self.current.playlist_id) else: log.error("Failed to retrieve playlist") def open_existing_playlist(self, checked: bool = False) -> None: """Open existing playlist""" playlists = ds.playlists_closed() dlg = SelectPlaylistDialog(self, playlists=playlists) dlg.exec() playlist = dlg.playlist if playlist: self._open_playlist(playlist) def save_as_template(self, checked: bool = False) -> None: """Save current playlist as template""" template_names = [a.name for a in ds.playlists_templates_all()] while True: # Get name for new template dlg = QInputDialog(self) dlg.setInputMode(QInputDialog.InputMode.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( self, "Duplicate template", "Template name already in use" ) ds.playlist_save_as_template(self.current.playlist_id, template_name) helpers.show_OK("Template", "Template saved", self) def get_playlist_name( self, default: str = "", prompt: str = "Playlist name:" ) -> str | None: """Get a name from the user""" dlg = QInputDialog(self) dlg.setInputMode(QInputDialog.InputMode.TextInput) dlg.setLabelText(prompt) all_playlist_names = [a.name for a in ds.playlists_all()] while True: if default: dlg.setTextValue(default) dlg.resize(500, 100) ok = dlg.exec() if ok: proposed_name = dlg.textValue() if proposed_name not in all_playlist_names: return proposed_name else: helpers.show_warning( self, "Name in use", f"There's already a playlist called '{proposed_name}'", ) continue else: return None def solicit_template_to_use(self, template_prompt: str | None = None) -> int | None: """ Have user select a template. Return the template.id, or None if they cancel. template_id of zero means don't use a template. """ template_name_id_list: list[tuple[str, int]] = [] template_name_id_list.append((Config.NO_TEMPLATE_NAME, 0)) for template in ds.playlists_templates_all(): template_name_id_list.append((template.name, template.playlist_id)) dlg = TemplateSelectorDialog(template_name_id_list, template_prompt) if not dlg.exec() or dlg.selected_id is None: return None # User cancelled return dlg.selected_id # # # # # # # # # # Manage templates and queries # # # # # # # # # # def manage_queries_wrapper(self, checked: bool = False) -> None: """ Simply instantiate the manage_queries class """ _ = ManageQueries(self) def manage_templates_wrapper(self, checked: bool = False) -> None: """ Simply instantiate the manage_templates class """ _ = ManageTemplates(self) def show_query(self, query_id: int) -> None: """ Show query dialog with query_id selected """ # Keep a reference else it will be gc'd self.query_dialog = QueryDialog(self.current.playlist_id, query_id) self.query_dialog.exec() # # # # # # # # # # Miscellaneous functions # # # # # # # # # # def select_duplicate_rows(self, checked: bool = False) -> None: """Call playlist to select duplicate rows""" self._active_tab().select_duplicate_rows() def about(self, checked: bool = False) -> 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) dbname = ds.db_name_get() QMessageBox.information( self, "About", f"MusicMuster {git_tag}\n\nDatabase: {dbname}", QMessageBox.StandardButton.Ok, ) def clear_next(self) -> None: """ Clear next track """ self.track_sequence.set_next(None) self.signals.signal_set_next_track.emit(None) def clear_selection(self, checked: bool = False) -> None: """Clear row selection""" # Unselect any selected rows if self._active_tab(): self._active_tab().clear_selection() # Clear the search bar self.search_playlist_clear() def close_playlist_tab(self, checked: bool = False) -> bool: """ Close active playlist tab, called by menu item """ return self.close_tab(self.playlist_section.tabPlaylist.currentIndex()) def close_tab(self, tab_index: int) -> bool: """ Close playlist tab unless it holds the current or next track. Called from close_playlist_tab() or by clicking close button on tab. Return True if tab closed else False. """ closing_tab_playlist_id = self.playlist_section.tabPlaylist.widget( tab_index ).playlist_id # Don't close current track playlist if self.track_sequence.current is not None: current_track_playlist_id = self.track_sequence.current.playlist_id if current_track_playlist_id: if closing_tab_playlist_id == current_track_playlist_id: helpers.show_OK( "Current track", "Can't close current track playlist", self ) return False # Don't close next track playlist if self.track_sequence.next is not None: next_track_playlist_id = self.track_sequence.next.playlist_id if next_track_playlist_id: if closing_tab_playlist_id == next_track_playlist_id: helpers.show_OK( "Next track", "Can't close next track playlist", self ) return False # Record playlist as closed ds.playlist_mark_status(closing_tab_playlist_id, open=False) # Close playlist and remove tab self.playlist_section.tabPlaylist.widget(tab_index).close() self.playlist_section.tabPlaylist.removeTab(tab_index) return True def connect_signals_slots(self) -> None: # Menu bars connections are in create_menu_bar() self.footer_section.btnDrop3db.clicked.connect(self.drop3db) self.footer_section.btnFade.clicked.connect(self.fade) self.footer_section.btnHidePlayed.clicked.connect(self.hide_played) self.footer_section.btnPreviewArm.clicked.connect(self.preview_arm) self.footer_section.btnPreviewBack.clicked.connect(self.preview_back) self.footer_section.btnPreview.clicked.connect(self.preview) self.footer_section.btnPreviewEnd.clicked.connect(self.preview_end) self.footer_section.btnPreviewFwd.clicked.connect(self.preview_fwd) self.footer_section.btnPreviewMark.clicked.connect(self.preview_mark) self.footer_section.btnPreviewStart.clicked.connect(self.preview_start) self.footer_section.btnStop.clicked.connect(self.stop) self.header_section.hdrCurrentTrack.clicked.connect(self.show_current) self.header_section.hdrNextTrack.clicked.connect(self.show_next) self.playlist_section.tabPlaylist.currentChanged.connect(self.tab_change) self.playlist_section.tabPlaylist.tabCloseRequested.connect(self.close_tab) self.tabBar = self.playlist_section.tabPlaylist.tabBar() self.txtSearch.textChanged.connect(self.search_playlist_text_changed) self.signals.enable_escape_signal.connect(self.enable_escape_signal_handler) self.signals.show_warning_signal.connect(self.show_warning) self.signals.signal_next_track_changed.connect(self.next_track_changed_handler) self.signals.signal_set_next_track.connect(self.set_next_track_handler) self.signals.status_message_signal.connect(self.show_status_message) self.signals.signal_track_ended.connect(self.track_ended_handler) self.signals.signal_playlist_selected_rows.connect(self.playlist_selected_rows_handler) self.timer10.timeout.connect(self.tick_10ms) self.timer500.timeout.connect(self.tick_500ms) self.timer100.timeout.connect(self.tick_100ms) self.timer1000.timeout.connect(self.tick_1000ms) # @log_call def current_row_or_end(self) -> int: """ If a row or rows are selected, return the row number of the first selected row otherwise return the row number for a new row at the of the playlist. """ # TODO should be able to have the model handle row depending on # how current_row_or_end is used if self.current.selected_row_numbers: return self.current.selected_row_numbers[0] if not self.current.base_model: return 0 # hack, but mostly there WILL be a current model return self.current.base_model.rowCount() def debug(self, checked: bool = False) -> None: """Invoke debugger""" import ipdb # type: ignore ipdb.set_trace() def download_played_tracks(self, checked: bool = False) -> 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: for playdate in ds.playdates_between_dates(start_dt): f.write(f"{playdate.artist},{playdate.title}\n") def drop3db(self) -> None: """Drop music level by 3db if button checked""" if self.track_sequence.current: self.track_sequence.current.drop3db( self.footer_section.btnDrop3db.isChecked() ) # @log_call def enable_escape_signal_handler(self, enabled: bool) -> None: """ Manage signal to enable/disable handling ESC character. Needed because we want to use ESC when editing playlist in place, so we need to disable it here while editing. """ if "clear_selection" in self.menu_actions: self.menu_actions["clear_selection"].setEnabled(enabled) # @log_call def track_ended_handler(self) -> None: """ Called by signal_track_ended Actions required: - Reset track_sequence objects - Tell playlist track has finished - Reset clocks - Update headers - Enable controls """ if self.track_sequence.current: self.track_sequence.move_current_to_previous() # Reset clocks self.footer_section.frame_fade.setStyleSheet("") self.footer_section.frame_silent.setStyleSheet("") self.footer_section.label_fade_timer.setText("00:00") self.footer_section.label_silent_timer.setText("00:00") # Update headers self.update_headers() # Enable controls self.catch_return_key = False self.show_status_message("Play controls: Enabled", 0) def export_playlist_tab(self, checked: bool = False) -> None: """Export the current playlist to an m3u file""" playlist_id = self.current.playlist_id playlist = ds.playlist_by_id(playlist_id) if not playlist: return # Get output filename 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 with open(path, "w") as f: # Required directive on first line f.write("#EXTM3U\n") for playlistrow in ds.playlistrows_by_playlist(playlist_id): if playlistrow.track: f.write( "#EXTINF:" f"{int(playlistrow.track.duration / 1000)}," f"{playlistrow.track.title} - " f"{playlistrow.track.artist}" "\n" f"{playlistrow.track.path}" "\n" ) def fade(self, checked: bool = False) -> None: """Fade currently playing track""" if self.track_sequence.current: self.track_sequence.current.fade() def get_tab_index_for_playlist(self, playlist_id: int) -> int | None: """ Return the tab index for the passed playlist_id if it is displayed, else return None. """ for idx in range(self.playlist_section.tabPlaylist.count()): if self.playlist_section.tabPlaylist.widget(idx).playlist_id == playlist_id: return idx return None def hide_played(self): """Toggle hide played tracks""" # TODO: handle this with signals, but first decide how to better # handle hide tracks / sections if self.hide_played_tracks: self.hide_played_tracks = False self.current.base_model.hide_played_tracks(False) self.footer_section.btnHidePlayed.setText("Hide played") else: self.hide_played_tracks = True self.footer_section.btnHidePlayed.setText("Show played") if Config.HIDE_PLAYED_MODE == Config.HIDE_PLAYED_MODE_SECTIONS: self._active_tab().hide_played_sections() else: self.current.base_model.hide_played_tracks(True) # Reset row heights self.signals.resize_rows_signal.emit(self.current.playlist_id) def import_files_wrapper(self, checked: bool = False) -> None: """ Pass import files call to file_importer module """ # We need to keep a reference to the FileImporter else it will be # garbage collected while import threads are still running self.importer = FileImporter(self.current.base_model, self.current_row_or_end()) self.importer.start() def insert_header(self, checked: bool = False) -> None: """Show dialog box to enter header text and add to playlist""" # Get header text dlg: QInputDialog = QInputDialog(self) dlg.setInputMode(QInputDialog.InputMode.TextInput) dlg.setLabelText("Header text:") dlg.resize(500, 100) ok = dlg.exec() if ok: self.signals.signal_insert_track.emit( InsertTrack( playlist_id=self.current.playlist_id, track_id=None, note=dlg.textValue(), ) ) def insert_track(self, checked: bool = False) -> None: """Show dialog box to select and add track from database""" dlg = TrackInsertDialog(parent=self, playlist_id=self.current.playlist_id) dlg.exec() # @log_call def load_last_playlists(self) -> None: """Load the playlists that were open when app was last closed""" playlist_ids = [] for playlist in ds.playlists_open(): if playlist: # Create tab playlist_ids.append(self._open_playlist(playlist)) # Set active tab value = ds.setting_get("active_index") if value is not None and value >= 0: self.playlist_section.tabPlaylist.setCurrentIndex(value) def lookup_row_in_songfacts(self, checked: bool = False) -> None: """ Display songfacts page for title in highlighted row """ track_info = self.selected_or_next_track_info() if not track_info: return self.open_songfacts_browser(track_info.title) def lookup_row_in_wikipedia(self, checked: bool = False) -> None: """ Display Wikipedia page for title in highlighted row or next track """ track_info = self.selected_or_next_track_info() if not track_info: return self.open_wikipedia_browser(track_info.title) def mark_rows_for_moving(self, checked: bool = False) -> None: """ Cut rows ready for pasting. """ # Save the selected PlaylistRows items ready for a later # paste self.move_source = MoveSource( model=self.current.base_model, rows=self.current.selected_row_numbers ) log.debug(f"mark_rows_for_moving(): {self.move_source=}") # @log_call def move_playlist_rows(self, row_numbers: list[int]) -> None: """ Move passed playlist rows to another playlist """ if not row_numbers: return # Identify destination playlist playlists = [] source_playlist_id = self.current.playlist_id for playlist in ds.playlists_all(): if playlist.id == source_playlist_id: continue else: playlists.append(playlist) dlg = SelectPlaylistDialog(self, playlists=playlists) dlg.exec() if not dlg.playlist: return to_playlist_id = dlg.playlist.id # Add to end of target playlist, so target row will be length of # playlist to_row = ds.playlist_row_count(to_playlist_id) # Move rows self.current.base_model.move_rows_between_playlists( row_numbers, to_row, to_playlist_id ) # Reset track_sequences self.track_sequence.update() def move_selected(self, checked: bool = False) -> None: """ Move selected rows to another playlist """ self.move_playlist_rows(self.current.selected_row_numbers) def move_unplayed(self, checked: bool = False) -> None: """ Move unplayed rows to another playlist """ unplayed_rows = self.current.base_model.get_unplayed_rows() if not unplayed_rows: return # We can get a race condition as selected rows change while # moving so disable selected rows timing for move self.disable_selection_timing = True self.move_playlist_rows(unplayed_rows) self.disable_selection_timing = False def open_songfacts_browser(self, title: str) -> None: """Search Songfacts for title""" slug = slugify(title, replacements=([["'", ""]])) log.info(f"Songfacts browser tab for {title=}") url = f"https://www.songfacts.com/search/songs/{slug}" webbrowser.get("browser").open_new_tab(url) def open_wikipedia_browser(self, title: str) -> None: """Search Wikipedia for title""" str = urllib.parse.quote_plus(title) log.info(f"Wikipedia browser tab for {title=}") url = f"https://www.wikipedia.org/w/index.php?search={str}" webbrowser.get("browser").open_new_tab(url) # @log_call def paste_rows(self, checked: bool = False) -> None: """ Paste earlier rows identified in self.mark_rows_for_moving() 'checked' is a dummy parameter passed to us by the menu """ if not self.move_source: return to_playlist_model = self.current.base_model from_playlist_model = self.move_source.model to_row = self.current_row_or_end() from_rows = self.move_source.rows if from_playlist_model == to_playlist_model: from_playlist_model.move_rows(from_rows, to_row) else: from_playlist_model.move_rows_between_playlists( from_rows, to_row, to_playlist_model.playlist_id ) self.signals.resize_rows_signal.emit(self.current.playlist_id) self._active_tab().clear_selection() # If we move a row to immediately under the current track, make # that moved row the next track if ( self.track_sequence.current and self.track_sequence.current.playlist_id == to_playlist_model.playlist_id and to_row == self.track_sequence.current.row_number + 1 ): to_playlist_model.set_next_row_handler(to_row) # @log_call def play_next(self, position: float | None = None, checked: bool = False) -> None: """ Play next track, optionally from passed position. Actions required: - If there is no next track set, return. - Check for inadvertent press of return - If there's currently a track playing, fade it. - Move next track to current track. - Clear next track - Restore volume if -3dB active - Play (new) current track. - Show fade graph - Notify playlist - Note that track is now playing - Disable play next controls - Update headers """ # If there is no next track set, return. if self.track_sequence.next is None: log.error("musicmuster.play_next(): no next track selected") return # Check for inadvertent press of 'return' if self.return_pressed_in_error(): return # Issue #223 concerns a very short pause (maybe 0.1s) sometimes # just after a track starts playing. Resolution appears to be to # disable timer10 for a short time. Timer is re-enabled in # update_clocks. self.timer10.stop() log.debug("issue223: play_next: 10ms timer disabled") # If there's currently a track playing, fade it. if self.track_sequence.current: self.track_sequence.current.fade() # Move next track to current track. signal_track_ended_handler() will # have been called when previous track ended or when fade() was # called above, and that in turn will have saved current track to # previous_track self.track_sequence.move_next_to_current() if self.track_sequence.current is None: raise ApplicationError("No current track") # Restore volume if -3dB active if self.footer_section.btnDrop3db.isChecked(): self.footer_section.btnDrop3db.setChecked(False) # Play (new) current track log.debug(f"Play: {self.track_sequence.current.title}") self.track_sequence.current.play(position) # Update clocks now, don't wait for next tick self.update_clocks() # Show closing volume graph if self.track_sequence.current.fade_graph: self.track_sequence.current.fade_graph.GraphWidget = ( self.footer_section.widgetFadeVolume ) self.track_sequence.current.fade_graph.clear() self.track_sequence.current.fade_graph.plot() # Disable play next controls self.catch_return_key = True self.show_status_message("Play controls: Disabled", 0) # Record playdate ds.playdates_update(self.track_sequence.current.track_id) # Notify others self.signals.signal_track_started.emit() # Update headers self.update_headers() def preview(self) -> None: """ Preview selected or next track. We use a different mechanism to normal track playing so that the user can route the output audio differently (eg, to headphones). """ if self.footer_section.btnPreview.isChecked(): # Get track path for first selected track if there is one track_info = self._active_tab().get_selected_row_track_info() if not track_info: # Otherwise get track_id to next track to play if self.track_sequence.next: if self.track_sequence.next.track_id: track_info = TrackInfo( self.track_sequence.next.track_id, self.track_sequence.next.row_number, ) if not track_info: return self.preview_manager.row_number = track_info.row_number track = ds.track_by_id(track_info.track_id) if not track: raise ApplicationError( f"musicmuster.preview: unable to retreive track {track_info.track_id=}" ) self.preview_manager.set_track_info( track_id=track.track_id, track_path=track.path, track_intro=track.intro or 0, ) self.preview_manager.play() self.show_status_message( f"Preview: {track.title} / {track.artist} (row {track_info.row_number})", 0, ) else: self.preview_manager.stop() self.show_status_message("", 0) def preview_arm(self): """Manager arm button for setting intro length""" self.footer_section.btnPreviewMark.setEnabled( self.footer_section.btnPreviewArm.isChecked() ) def preview_back(self) -> None: """Wind back preview file""" self.preview_manager.back(5000) def preview_end(self) -> None: """Advance preview file to Config.PREVIEW_END_BUFFER_MS before end of intro""" if self.preview_manager: self.preview_manager.move_to_intro_end() def preview_fwd(self) -> None: """Advance preview file""" self.preview_manager.forward(5000) def preview_mark(self) -> None: """Set intro time""" if self.preview_manager.is_playing(): track_id = self.preview_manager.track_id row_number = self.preview_manager.row_number if not row_number: return intro = round(self.preview_manager.get_playtime() / 100) * 100 ds.track_update(track_id, dict(intro=intro)) self.preview_manager.set_intro(intro) self.current.base_model.refresh_row(row_number) roles = [ Qt.ItemDataRole.DisplayRole, ] self.current.base_model.invalidate_row(row_number, roles) def preview_start(self) -> None: """Restart preview""" self.preview_manager.restart() def quicklog(self) -> None: """ Create log entry """ log.debug("quicklog timestamp; entry follows") # Get log text dlg: QInputDialog = QInputDialog(self) dlg.setInputMode(QInputDialog.InputMode.TextInput) dlg.setLabelText("Log text:") dlg.resize(500, 100) ok = dlg.exec() if ok: log.debug("quicklog: " + dlg.textValue()) def rename_playlist(self, checked: bool = False) -> None: """ Rename current playlist. checked is passed by menu but not used here """ playlist = ds.playlist_by_id(self.current.playlist_id) if playlist: new_name = self.get_playlist_name(playlist.name) if new_name: ds.playlist_rename(playlist.playlist_id, new_name) idx = self.tabBar.currentIndex() self.tabBar.setTabText(idx, new_name) def return_pressed_in_error(self) -> bool: """ Check whether Return key has been pressed in error. Return True if it has, False if not """ if self.track_sequence.current and self.catch_return_key: # Suppress inadvertent double press if ( self.track_sequence.current and self.track_sequence.current.start_time and self.track_sequence.current.start_time + dt.timedelta(milliseconds=Config.RETURN_KEY_DEBOUNCE_MS) > dt.datetime.now() ): return True # If return is pressed during first PLAY_NEXT_GUARD_MS then # default to NOT playing the next track, else default to # playing it. default_yes: bool = self.track_sequence.current.start_time is not None and ( ( dt.datetime.now() - self.track_sequence.current.start_time ).total_seconds() * 1000 > Config.PLAY_NEXT_GUARD_MS ) if default_yes: msg = "Hit return to play next track now" else: msg = "Press tab to select Yes and hit return to play next track" if not helpers.ask_yes_no( "Play next track", msg, default_yes=default_yes, parent=self, ): return True return False def resume(self, checked: bool = False) -> None: """ Resume playing last track. We may be playing the next track or none; take care of both eventualities. Actions required: - Return if no saved position - Resume last track - If a track is playing, make that the next track """ if not self.track_sequence.previous: return # Return if no saved position resume_marker = self.track_sequence.previous.resume_marker if not resume_marker: log.error("No previous track position") return # We want to use play_next() to resume, so copy the previous # track to the next track: self.track_sequence.move_previous_to_next() # Now resume playing the now-next track self.play_next(resume_marker) # Adjust track info so that clocks and graph are correct. # We need to fake the start time to reflect where we resumed the # track if ( self.track_sequence.current and self.track_sequence.current.start_time and self.track_sequence.current.duration and self.track_sequence.current.resume_marker ): elapsed_ms = ( self.track_sequence.current.duration * self.track_sequence.current.resume_marker ) self.track_sequence.current.start_time -= dt.timedelta( milliseconds=elapsed_ms ) def search_playlist(self, checked: bool = False) -> None: """Show text box to search playlist""" # Disable play controls so that 'return' in search box doesn't # play next track self.catch_return_key = True self.txtSearch.setVisible(True) 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""" # Clean up search bar self.txtSearch.setText("") self.txtSearch.setHidden(True) def search_playlist_text_changed(self) -> None: """ Incremental search of playlist """ self.current.proxy_model.set_incremental_search(self.txtSearch.text()) def selected_or_next_track_info(self) -> PlaylistRow | None: """ Return RowAndTrack info for selected track. If no selected track, return for next track. If no next track, return None. """ row_number: int | None = None if self.current.selected_row_numbers: row_number = self.current.selected_row_numbers[0] if row_number is None: if self.track_sequence.next: if self.track_sequence.next.track_id: row_number = self.track_sequence.next.row_number if row_number is None: return None track_info = self.current.base_model.get_row_info(row_number) if track_info is None: return None return track_info def set_main_window_size(self) -> None: """Set size of window from database""" x = ds.setting_get("mainwindow_x") or 100 y = ds.setting_get("mainwindow_y") or 100 width = ds.setting_get("mainwindow_width") or 100 height = ds.setting_get("mainwindow_height") or 100 self.setGeometry(x, y, width, height) # @log_call def set_selected_track_next(self, checked: bool = False) -> None: """ Set currently-selected row on visible playlist tab as next track """ self.signals.signal_set_next_row.emit(self.current.playlist_id) self.clear_selection() def show_current(self) -> None: """Scroll to show current track""" if self.track_sequence.current: self.show_track(self.track_sequence.current) def show_warning(self, title: str, body: str) -> None: """ Handle show_warning_signal and display a warning dialog """ QMessageBox.warning(self, title, body) def show_next(self) -> None: """Scroll to show next track""" if self.track_sequence.next: self.show_track(self.track_sequence.next) def show_status_message(self, message: str, timing: int) -> None: """ Handle status_message_signal. Show status message in status bar for timing milliseconds Clear message if message is null string """ if self.statusbar: if message: self.statusbar.showMessage(message, timing) else: self.statusbar.clearMessage() def show_track(self, playlist_track: PlaylistRow) -> None: """Scroll to show track""" # Switch to the correct tab playlist_id = playlist_track.playlist_id if not playlist_id: # No playlist return # Switch to correct tab if playlist_id != self.current.playlist_id: open_idx = self.get_tab_index_for_playlist(playlist_id) if open_idx: self.playlist_section.tabPlaylist.setCurrentIndex(open_idx) else: raise ApplicationError( f"show_track() can't find current playlist tab {playlist_id=}" ) self._active_tab().scroll_to_top(playlist_track.row_number) def signal_playlist_selected_rows_handler(self, selected_rows: SelectedRows) -> None: """ Handle signal_playlist_selected_rows to keep track of which rows are selected in the current model """ self.current.selected_row_numbers = selected_rows.rows def set_next_track_handler(self, plr: PlaylistRow) -> None: """ Handle signal_set_next_track """ self.track_sequence.set_next(plr) self.signals.signal_next_track_changed.emit() def next_track_changed_handler(self) -> None: """ Handle next track changed """ self.update_headers() # @log_call def stop(self, checked: bool = False) -> None: """Stop playing immediately""" if self.track_sequence.current: self.track_sequence.current.stop() def tab_change(self) -> None: """Called when active tab changed""" self._active_tab().tab_live() def tick_10ms(self) -> None: """ Called every 10ms """ if self.track_sequence.current: self.track_sequence.current.update_fade_graph() def tick_100ms(self) -> None: """ Called every 100ms """ if self.track_sequence.current: try: # Update intro counter if applicable and, if updated, # return because playing an intro uses the intro field to # show timing and this takes precedence over timing a # preview. intro_ms_remaining = self.track_sequence.current.time_remaining_intro() if intro_ms_remaining > 0: self.footer_section.label_intro_timer.setText( f"{intro_ms_remaining / 1000:.1f}" ) if intro_ms_remaining <= Config.INTRO_SECONDS_WARNING_MS: self.footer_section.label_intro_timer.setStyleSheet( f"background: {Config.COLOUR_WARNING_TIMER}" ) return else: if self.footer_section.label_intro_timer.styleSheet() != "": self.footer_section.label_intro_timer.setStyleSheet("") self.footer_section.label_intro_timer.setText("0.0") except AttributeError: # current track ended during servicing tick pass # Update preview timer if self.footer_section.btnPreview.isChecked(): if self.preview_manager.is_playing(): self.footer_section.btnPreview.setChecked(True) minutes, seconds = divmod( self.preview_manager.get_playtime() / 1000, 60 ) self.footer_section.label_intro_timer.setText( f"{int(minutes)}:{seconds:04.1f}" ) else: # Ensure preview button is reset if preview has finished # playing self.footer_section.btnPreview.setChecked(False) self.footer_section.label_intro_timer.setText("0.0") self.footer_section.label_intro_timer.setStyleSheet("") self.footer_section.btnPreview.setChecked(False) def tick_1000ms(self) -> None: """ Called every 1000ms """ # Only update play clocks once a second so that their updates # are synchronised (otherwise it looks odd) self.update_clocks() def tick_500ms(self) -> None: """ Called every 500ms """ self.header_section.lblTOD.setText( dt.datetime.now().strftime(Config.TOD_TIME_FORMAT) ) def update_clocks(self) -> None: """ Update track clocks. """ # If track is playing, update track clocks time and colours if self.track_sequence.current and self.track_sequence.current.music.is_playing(): # Elapsed time self.header_section.label_elapsed_timer.setText( helpers.ms_to_mmss(self.track_sequence.current.time_playing()) + " / " + helpers.ms_to_mmss(self.track_sequence.current.duration) ) # Time to fade time_to_fade = self.track_sequence.current.time_to_fade() time_to_silence = self.track_sequence.current.time_to_silence() self.footer_section.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 <= Config.WARNING_MS_BEFORE_SILENCE: css_silence = f"background: {Config.COLOUR_ENDING_TIMER}" if self.footer_section.frame_silent.styleSheet() != css_silence: self.footer_section.frame_silent.setStyleSheet(css_silence) self.catch_return_key = False self.show_status_message("Play controls: Enabled", 0) # Set warning colour on time to silence box when fade starts elif time_to_fade <= 500: css_fade = f"background: {Config.COLOUR_WARNING_TIMER}" if self.footer_section.frame_silent.styleSheet() != css_fade: self.footer_section.frame_silent.setStyleSheet(css_fade) # WARNING_MS_BEFORE_FADE milliseconds before fade starts, set # warning colour on time to silence box and enable play # controls. This is also a good time to re-enable the 10ms # timer (see play_next() and issue #223). elif time_to_fade <= Config.WARNING_MS_BEFORE_FADE: self.footer_section.frame_fade.setStyleSheet( f"background: {Config.COLOUR_WARNING_TIMER}" ) self.catch_return_key = False self.show_status_message("Play controls: Enabled", 0) # Re-enable 10ms timer (see above) log.debug(f"issue287: {self.timer10.isActive()=}") if not self.timer10.isActive(): self.timer10.start(10) log.debug("issue223: update_clocks: 10ms timer enabled") else: self.footer_section.frame_silent.setStyleSheet("") self.footer_section.frame_fade.setStyleSheet("") self.footer_section.label_silent_timer.setText( helpers.ms_to_mmss(time_to_silence) ) def update_headers(self) -> None: """ Update last / current / next track headers """ if self.track_sequence.previous: self.header_section.hdrPreviousTrack.setText( f"{self.track_sequence.previous.title} - {self.track_sequence.previous.artist}" ) else: self.header_section.hdrPreviousTrack.setText("") if self.track_sequence.current: self.header_section.hdrCurrentTrack.setText( f"{self.track_sequence.current.title.replace('&', '&&')} - " f"{self.track_sequence.current.artist.replace('&', '&&')}" ) else: self.header_section.hdrCurrentTrack.setText("") if self.track_sequence.next: self.header_section.hdrNextTrack.setText( f"{self.track_sequence.next.title.replace('&', '&&')} - " f"{self.track_sequence.next.artist.replace('&', '&&')}" ) else: self.header_section.hdrNextTrack.setText("") self.update_playlist_icons() def update_playlist_icons(self) -> None: """ Set current / next playlist tab icons """ # Do we need to set a 'next' icon? set_next = True if ( self.track_sequence.current and self.track_sequence.next and self.track_sequence.current.playlist_id == self.track_sequence.next.playlist_id ): set_next = False for idx in range(self.tabBar.count()): widget = self.playlist_section.tabPlaylist.widget(idx) if ( self.track_sequence.next and set_next and widget.playlist_id == self.track_sequence.next.playlist_id ): self.playlist_section.tabPlaylist.setTabIcon( idx, QIcon(Config.PLAYLIST_ICON_NEXT) ) elif ( self.track_sequence.current and widget.playlist_id == self.track_sequence.current.playlist_id ): self.playlist_section.tabPlaylist.setTabIcon( idx, QIcon(Config.PLAYLIST_ICON_CURRENT) ) elif ( self.playlist_section.tabPlaylist.widget(idx) .model() .sourceModel() .is_template ): self.playlist_section.tabPlaylist.setTabIcon( idx, QIcon(Config.PLAYLIST_ICON_TEMPLATE) ) else: self.playlist_section.tabPlaylist.setTabIcon(idx, QIcon()) 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("Checking database") check_db() elif args.update_bitrates: log.debug("Update bitrates") update_bitrates() else: app = QApplication(sys.argv) try: # PyQt6 defaults to a grey for labels palette = app.palette() palette.setColor( QPalette.ColorRole.WindowText, QColor(Config.COLOUR_LABEL_TEXT) ) # Set colours that will be used by playlist row stripes palette.setColor( QPalette.ColorRole.Base, QColor(Config.COLOUR_EVEN_PLAYLIST) ) palette.setColor( QPalette.ColorRole.AlternateBase, QColor(Config.COLOUR_ODD_PLAYLIST) ) app.setPalette(palette) win = Window() win.show() status = app.exec() sys.exit(status) except Exception as exc: if os.environ["MM_ENV"] == "PRODUCTION": from helpers import send_mail msg = stackprinter.format(exc) send_mail( ",".join(Config.ERRORS_TO), ",".join(Config.ERRORS_FROM), "Exception from musicmuster.py", msg, ) log.debug(msg) else: print( stackprinter.format( exc, suppressed_paths=["/pypoetry/virtualenvs/"], style="darkbg" ) )