Compare commits

...

7 Commits

Author SHA1 Message Date
Keith Edmunds
639f006a10 Add favourite to playlists 2025-02-22 20:23:07 +00:00
Keith Edmunds
9e27418f80 Remove queries table definition
It mistakenly was introduced to the wrong branch. It persists on the
query_tabs branch.
2025-02-22 20:13:44 +00:00
Keith Edmunds
c1448dfdd5 WIP: manage templates: template rows have different background 2025-02-22 19:42:48 +00:00
Keith Edmunds
5f396a0993 WIP: template management: new, rename, delete working 2025-02-22 19:16:42 +00:00
Keith Edmunds
e10c2adafe WIP: template management: edit and delete working 2025-02-22 11:34:36 +00:00
Keith Edmunds
b0f6e4e819 Framework for dynamic submenus 2025-02-21 15:18:45 +00:00
Keith Edmunds
afd3be608c Move menu definitions to YAML file 2025-02-21 14:16:34 +00:00
8 changed files with 579 additions and 207 deletions

View File

@ -31,6 +31,7 @@ class Config(object):
COLOUR_NORMAL_TAB = "#000000" COLOUR_NORMAL_TAB = "#000000"
COLOUR_NOTES_PLAYLIST = "#b8daff" COLOUR_NOTES_PLAYLIST = "#b8daff"
COLOUR_ODD_PLAYLIST = "#f2f2f2" COLOUR_ODD_PLAYLIST = "#f2f2f2"
COLOUR_TEMPLATE_ROW = "#FFAF68"
COLOUR_UNREADABLE = "#dc3545" COLOUR_UNREADABLE = "#dc3545"
COLOUR_WARNING_TIMER = "#ffc107" COLOUR_WARNING_TIMER = "#ffc107"
DBFS_SILENCE = -50 DBFS_SILENCE = -50

View File

@ -80,8 +80,8 @@ class PlaylistsTable(Model):
cascade="all, delete-orphan", cascade="all, delete-orphan",
order_by="PlaylistRowsTable.row_number", order_by="PlaylistRowsTable.row_number",
) )
query: Mapped["QueriesTable"] = relationship( favourite: Mapped[bool] = mapped_column(
back_populates="playlist", cascade="all, delete-orphan" Boolean, nullable=False, index=False, default=False
) )
def __repr__(self) -> str: def __repr__(self) -> str:
@ -121,20 +121,6 @@ class PlaylistRowsTable(Model):
) )
class QueriesTable(Model):
__tablename__ = "queries"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
query: Mapped[str] = mapped_column(
String(2048), index=False, default="", nullable=False
)
playlist_id: Mapped[int] = mapped_column(ForeignKey("playlists.id"), index=True)
playlist: Mapped[PlaylistsTable] = relationship(back_populates="query")
def __repr__(self) -> str:
return f"<Queries(id={self.id}, playlist={self.playlist}, query={self.query}>"
class SettingsTable(Model): class SettingsTable(Model):
"""Manage settings""" """Manage settings"""

View File

@ -203,12 +203,12 @@ class Playlists(dbtables.PlaylistsTable):
@classmethod @classmethod
def create_playlist_from_template( def create_playlist_from_template(
cls, session: Session, template: "Playlists", playlist_name: str cls, session: Session, template_id: int, playlist_name: str
) -> Optional["Playlists"]: ) -> Optional["Playlists"]:
"""Create a new playlist from template""" """Create a new playlist from template"""
# Sanity check # Sanity check
if not template.id: if not template_id:
return None return None
playlist = cls(session, playlist_name) playlist = cls(session, playlist_name)
@ -217,7 +217,7 @@ class Playlists(dbtables.PlaylistsTable):
if not playlist or not playlist.id: if not playlist or not playlist.id:
return None return None
PlaylistRows.copy_playlist(session, template.id, playlist.id) PlaylistRows.copy_playlist(session, template_id, playlist.id)
return playlist return playlist

View File

@ -1,15 +1,18 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# Standard library imports # Standard library imports
from __future__ import annotations
from slugify import slugify # type: ignore from slugify import slugify # type: ignore
from typing import Callable, Optional from typing import Callable, Optional
import argparse import argparse
from dataclasses import dataclass
import datetime as dt import datetime as dt
import os import os
import subprocess import subprocess
import sys import sys
import urllib.parse import urllib.parse
import webbrowser import webbrowser
import yaml
# PyQt imports # PyQt imports
from PyQt6.QtCore import ( from PyQt6.QtCore import (
@ -22,6 +25,7 @@ from PyQt6.QtGui import (
QAction, QAction,
QCloseEvent, QCloseEvent,
QColor, QColor,
QFont,
QIcon, QIcon,
QKeySequence, QKeySequence,
QPalette, QPalette,
@ -29,6 +33,7 @@ from PyQt6.QtGui import (
) )
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QApplication, QApplication,
QCheckBox,
QComboBox, QComboBox,
QDialog, QDialog,
QFileDialog, QFileDialog,
@ -38,8 +43,11 @@ from PyQt6.QtWidgets import (
QLineEdit, QLineEdit,
QListWidgetItem, QListWidgetItem,
QMainWindow, QMainWindow,
QMenu,
QMessageBox, QMessageBox,
QPushButton, QPushButton,
QTableWidget,
QTableWidgetItem,
QVBoxLayout, QVBoxLayout,
QWidget, QWidget,
) )
@ -75,18 +83,6 @@ from utilities import check_db, update_bitrates
import helpers import helpers
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 Current: class Current:
base_model: PlaylistModel base_model: PlaylistModel
proxy_model: PlaylistProxyModel proxy_model: PlaylistProxyModel
@ -100,6 +96,18 @@ class Current:
) )
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): class EditDeleteDialog(QDialog):
def __init__(self, templates: list[tuple[str, int]]) -> None: def __init__(self, templates: list[tuple[str, int]]) -> None:
super().__init__() super().__init__()
@ -158,6 +166,134 @@ class EditDeleteDialog(QDialog):
self.reject() self.reject()
@dataclass
class ItemlistItem:
id: int
name: str
favourite: bool = False
class ItemlistManager(QDialog):
def __init__(
self, items: list[ItemlistItem], callbacks: ItemlistManagerCallbacks
) -> None:
super().__init__()
self.setWindowTitle("Item Manager")
self.setMinimumSize(600, 400)
self.items = items
self.callbacks = callbacks
layout = QVBoxLayout(self)
self.table = QTableWidget(len(items), 2, self)
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, 200)
self.table.setColumnWidth(1, 300)
self.populate_table()
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) -> None:
"""Populates the table with items and action buttons."""
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:
self.callbacks.delete(item_id)
def edit_item(self, item_id: int) -> None:
self.callbacks.edit(item_id)
def rename_item(self, item_id: int) -> None:
new_name = self.callbacks.rename(item_id)
if not new_name:
return
# Rename item in list
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_name)
self.items[row].name = new_name
break
def toggle_favourite(self, item_id: int, checked: bool) -> None:
print(f"Toggle favourite for item {item_id}: {checked}")
self.callbacks.favourite(item_id, checked)
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
def new_item(self) -> None:
self.callbacks.new_item()
@dataclass
class ItemlistManagerCallbacks:
delete: Callable[[int], None]
edit: Callable[[int], None]
favourite: Callable[[int, bool], None]
new_item: Callable[[], None]
rename: Callable[[int], Optional[str]]
class PreviewManager: class PreviewManager:
""" """
Manage track preview player Manage track preview player
@ -320,16 +456,21 @@ class TemplateSelectorDialog(QDialog):
Class to manage user selection of template Class to manage user selection of template
""" """
def __init__(self, templates: list[tuple[str, int]]) -> None: def __init__(
self, templates: list[tuple[str, int]], template_prompt: Optional[str]
) -> None:
super().__init__() super().__init__()
self.templates = templates self.templates = templates
self.template_prompt = template_prompt
self.selected_id = None self.selected_id = None
self.init_ui() self.init_ui()
def init_ui(self): def init_ui(self):
# Create label # Create label
label = QLabel("Select template:") if not self.template_prompt:
self.template_prompt = "Select template:"
label = QLabel(self.template_prompt)
# Create combo box # Create combo box
self.combo_box = QComboBox() self.combo_box = QComboBox()
@ -475,104 +616,85 @@ class Window(QMainWindow):
return action return action
def create_menu_bar(self): def create_menu_bar(self):
"""Dynamically creates the menu bar from a YAML file."""
menu_bar = self.menuBar() menu_bar = self.menuBar()
# File Menu # Load menu structure from YAML file
file_menu = menu_bar.addMenu("&File") with open("menu.yaml", "r") as file:
file_menu.addAction( menu_data = yaml.safe_load(file)
self.create_action("Open Playlist", self.open_playlist, "Ctrl+O")
)
file_menu.addAction(self.create_action("New Playlist", self.new_playlist))
file_menu.addAction(
self.create_action("Close Playlist", self.close_playlist_tab)
)
file_menu.addAction(self.create_action("Rename Playlist", self.rename_playlist))
file_menu.addAction(self.create_action("Delete Playlist", self.delete_playlist))
file_menu.addSeparator()
file_menu.addAction(
self.create_action("Save as Template", self.save_as_template)
)
file_menu.addAction(
self.create_action("Manage Templates", self.manage_templates)
)
file_menu.addSeparator()
file_menu.addAction(
self.create_action(
"Import Files", self.import_files_wrapper, "Ctrl+Shift+I"
)
)
file_menu.addSeparator()
file_menu.addAction(self.create_action("Exit", self.close))
# Playlist Menu self.menu_actions = {} # Store reference for enabling/disabling actions
playlist_menu = menu_bar.addMenu("&Playlist") self.dynamic_submenus = {} # Store submenus for dynamic population
playlist_menu.addSeparator()
playlist_menu.addAction(
self.create_action("Insert Track", self.insert_track, "Ctrl+T")
)
playlist_menu.addAction(
self.create_action("Insert Section Header", self.insert_header, "Ctrl+H")
)
playlist_menu.addSeparator()
playlist_menu.addAction(
self.create_action("Mark for Moving", self.mark_rows_for_moving, "Ctrl+C")
)
playlist_menu.addAction(self.create_action("Paste", self.paste_rows, "Ctrl+V"))
playlist_menu.addSeparator()
playlist_menu.addAction(
self.create_action("Export Playlist", self.export_playlist_tab)
)
playlist_menu.addAction(
self.create_action(
"Download CSV of Played Tracks", self.download_played_tracks
)
)
playlist_menu.addSeparator()
playlist_menu.addAction(
self.create_action(
"Select Duplicate Rows",
lambda: self.active_tab().select_duplicate_rows(),
)
)
playlist_menu.addAction(self.create_action("Move Selected", self.move_selected))
playlist_menu.addAction(self.create_action("Move Unplayed", self.move_unplayed))
# Clear Selection with Escape key. Save in module so we can for menu_item in menu_data["menus"]:
# enable/disable it later menu = menu_bar.addMenu(menu_item["title"])
self.action_Clear_selection = self.create_action(
"Clear Selection", self.clear_selection, "Esc"
)
playlist_menu.addAction(self.action_Clear_selection)
# Music Menu for action_item in menu_item["actions"]:
music_menu = menu_bar.addMenu("&Music") if "separator" in action_item and action_item["separator"]:
music_menu.addAction( menu.addSeparator()
self.create_action("Set Next", self.set_selected_track_next, "Ctrl+N") continue
)
music_menu.addAction(self.create_action("Play Next", self.play_next, "Return"))
music_menu.addAction(self.create_action("Fade", self.fade, "Ctrl+Z"))
music_menu.addAction(self.create_action("Stop", self.stop, "Ctrl+Alt+S"))
music_menu.addAction(self.create_action("Resume", self.resume, "Ctrl+R"))
music_menu.addAction(
self.create_action("Skip to Next", self.play_next, "Ctrl+Alt+Return")
)
music_menu.addSeparator()
music_menu.addAction(self.create_action("Search", self.search_playlist, "/"))
music_menu.addAction(
self.create_action(
"Search Title in Wikipedia", self.lookup_row_in_wikipedia, "Ctrl+W"
)
)
music_menu.addAction(
self.create_action(
"Search Title in Songfacts", self.lookup_row_in_songfacts, "Ctrl+S"
)
)
# Help Menu # Check whether this is a submenu first
help_menu = menu_bar.addMenu("Help") if action_item.get("submenu"):
help_menu.addAction(self.create_action("About", self.about)) submenu = QMenu(action_item["text"], self)
help_menu.addAction(self.create_action("Debug", self.debug)) 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 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:
action = QAction(item["text"], self)
action.triggered.connect(
lambda _, i=item["handler"]: getattr(self, i)()
)
submenu.addAction(action)
break
def get_new_playlist_dynamic_submenu_items(self):
"""Returns dynamically generated menu items for Submenu 1."""
return [
{"text": "Option A", "handler": "option_a_handler"},
{"text": "Option B", "handler": "option_b_handler"},
]
def get_query_dynamic_submenu_items(self):
"""Returns dynamically generated menu items for Submenu 2."""
return [
{"text": "Action X", "handler": "action_x_handler"},
{"text": "Action Y", "handler": "action_y_handler"},
]
def select_duplicate_rows(self) -> None:
"""Call playlist to select duplicate rows"""
self.active_tab().select_duplicate_rows()
def about(self) -> None: def about(self) -> None:
"""Get git tag and database name""" """Get git tag and database name"""
@ -749,13 +871,23 @@ class Window(QMainWindow):
self.signals.search_wikipedia_signal.connect(self.open_wikipedia_browser) self.signals.search_wikipedia_signal.connect(self.open_wikipedia_browser)
def create_playlist( def create_playlist(
self, session: Session, playlist_name: str self, session: Session, template_id: int
) -> Optional[Playlists]: ) -> Optional[Playlists]:
"""Create new playlist""" """Create new playlist"""
log.debug(f"create_playlist({playlist_name=}") # Get a name for this new playlist
playlist_name = self.solicit_playlist_name(session)
if not playlist_name:
return None
playlist = Playlists(session, playlist_name) # If template.id == 0, user doesn't want a template
playlist: Optional[Playlists]
if template_id == 0:
playlist = Playlists(session, playlist_name)
else:
playlist = Playlists.create_playlist_from_template(
session, template_id, playlist_name
)
if playlist: if playlist:
return playlist return playlist
@ -764,7 +896,7 @@ class Window(QMainWindow):
return None return None
def create_playlist_tab(self, playlist: Playlists) -> int: def create_playlist_tab(self, playlist: Playlists, is_template: bool = False) -> int:
""" """
Take the passed playlist, create a playlist tab and Take the passed playlist, create a playlist tab and
add tab to display. Return index number of tab. add tab to display. Return index number of tab.
@ -773,7 +905,7 @@ class Window(QMainWindow):
log.debug(f"create_playlist_tab({playlist=})") log.debug(f"create_playlist_tab({playlist=})")
# Create model and proxy model # Create model and proxy model
base_model = PlaylistModel(playlist.id) base_model = PlaylistModel(playlist.id, is_template)
proxy_model = PlaylistProxyModel() proxy_model = PlaylistProxyModel()
proxy_model.setSourceModel(base_model) proxy_model.setSourceModel(base_model)
@ -863,7 +995,8 @@ class Window(QMainWindow):
log.debug(f"enable_escape({enabled=})") log.debug(f"enable_escape({enabled=})")
self.action_Clear_selection.setEnabled(enabled) if "clear_selection" in self.menu_actions:
self.menu_actions["clear_selection"].setEnabled(enabled)
def end_of_track_actions(self) -> None: def end_of_track_actions(self) -> None:
""" """
@ -951,6 +1084,18 @@ class Window(QMainWindow):
if track_sequence.current: if track_sequence.current:
track_sequence.current.fade() track_sequence.current.fade()
def get_tab_index_for_playlist(self, playlist_id: int) -> Optional[int]:
"""
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): def hide_played(self):
"""Toggle hide played tracks""" """Toggle hide played tracks"""
@ -1057,42 +1202,105 @@ class Window(QMainWindow):
Delete / edit templates Delete / edit templates
""" """
# Build a list of (template-name, playlist-id) tuples # Define callbacks to handle management options
template_list: list[tuple[str, int]] = [] def delete(template_id: int) -> None:
"""delete template"""
template = session.get(Playlists, template_id)
if not template:
raise ApplicationError(
f"manage_templeate.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.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.playlist_section.tabPlaylist.removeTab(open_idx)
log.info(f"manage_templates: delete {template=}")
template.delete(session)
session.commit()
def edit(template_id: int) -> None:
"""Edit template"""
template = session.get(Playlists, template_id)
if not template:
raise ApplicationError(
f"manage_templeate.edit({template_id=}) can't load template"
)
# Simply load the template as a playlist. Any changes
# made will persist
idx = self.create_playlist_tab(template, is_template=True)
self.playlist_section.tabPlaylist.setCurrentIndex(idx)
def favourite(template_id: int, favourite: bool) -> None:
"""Mark template as (not) favourite"""
print(f"manage_templates.favourite({template_id=}")
print(f"{session=}")
def new_item() -> None:
"""Create new template"""
# Get base template
template_id = self.solicit_template_to_use(
session, template_prmompt="New template based upon:"
)
if template_id is None:
return
new_template = self.create_playlist(session, template_id)
if new_template:
self.open_playlist(session, new_template, is_template=True)
def rename(template_id: int) -> Optional[str]:
"""rename template"""
template = session.get(Playlists, template_id)
if not template:
raise ApplicationError(
f"manage_templeate.delete({template_id=}) can't load template"
)
new_name = self.solicit_playlist_name(session, template.name)
if new_name:
template.rename(session, new_name)
idx = self.tabBar.currentIndex()
self.tabBar.setTabText(idx, new_name)
session.commit()
return new_name
return None
# Call listitem management dialog to manage templates
callbacks = ItemlistManagerCallbacks(
delete=delete,
edit=edit,
favourite=favourite,
new_item=new_item,
rename=rename,
)
# Build a list of templates
template_list: list[ItemlistItem] = []
with db.Session() as session: with db.Session() as session:
for template in Playlists.get_all_templates(session): for template in Playlists.get_all_templates(session):
template_list.append((template.name, template.id)) # TODO: need to add in favourites
template_list.append(ItemlistItem(name=template.name, id=template.id))
# Get user's selection # We need to retain a reference to the dialog box to stop it
dlg = EditDeleteDialog(template_list) # going out of scope and being garbage-collected.
if not dlg.exec(): self.dlg = ItemlistManager(template_list, callbacks)
return # User cancelled self.dlg.show()
action, template_id = dlg.selection
playlist = session.get(Playlists, template_id)
if not playlist:
log.error(f"Error opening {template_id=}")
if action == "Edit":
# Simply load the template as a playlist. Any changes
# made will persist
idx = self.create_playlist_tab(playlist)
self.playlist_section.tabPlaylist.setCurrentIndex(idx)
elif action == "Delete":
if helpers.ask_yes_no(
"Delete template",
f"Delete template '{playlist.name}': " "Are you sure?",
):
if self.close_playlist_tab():
playlist.delete(session)
session.commit()
else:
raise ApplicationError(
f"Unrecognised action from EditDeleteDialog: {action=}"
)
def mark_rows_for_moving(self) -> None: def mark_rows_for_moving(self) -> None:
""" """
@ -1177,37 +1385,17 @@ class Window(QMainWindow):
self.move_playlist_rows(unplayed_rows) self.move_playlist_rows(unplayed_rows)
self.disable_selection_timing = False self.disable_selection_timing = False
def new_playlist(self) -> None: def new_playlist(self) -> Optional[Playlists]:
""" """
Create new playlist, optionally from template Create new playlist, optionally from template
""" """
# Build a list of (template-name, playlist-id) tuples starting
# with the "no template" entry
template_list: list[tuple[str, int]] = []
template_list.append((Config.NO_TEMPLATE_NAME, 0))
with db.Session() as session: with db.Session() as session:
for template in Playlists.get_all_templates(session): template_id = self.solicit_template_to_use(session)
template_list.append((template.name, template.id)) if not template_id:
return None # User cancelled
dlg = TemplateSelectorDialog(template_list) playlist = self.create_playlist(session, template_id)
if not dlg.exec():
return # User cancelled
template_id = dlg.selected_id
# Get a name for this new playlist
playlist_name = self.solicit_playlist_name(session)
if not playlist_name:
return
# If template_id == 0, user doesn't want a template
if template_id == 0:
playlist = self.create_playlist(session, playlist_name)
else:
playlist = Playlists.create_playlist_from_template(
session, template, playlist_name
)
if playlist: if playlist:
playlist.mark_open() playlist.mark_open()
@ -1216,10 +1404,13 @@ class Window(QMainWindow):
session.commit() session.commit()
idx = self.create_playlist_tab(playlist) idx = self.create_playlist_tab(playlist)
self.playlist_section.tabPlaylist.setCurrentIndex(idx) self.playlist_section.tabPlaylist.setCurrentIndex(idx)
return playlist
else: else:
log.error("Playlist failed to create") ApplicationError("new_playlist: Playlist failed to create")
def open_playlist(self) -> None: return None
def open_existing_playlist(self) -> None:
"""Open existing playlist""" """Open existing playlist"""
with db.Session() as session: with db.Session() as session:
@ -1228,11 +1419,18 @@ class Window(QMainWindow):
dlg.exec() dlg.exec()
playlist = dlg.playlist playlist = dlg.playlist
if playlist: if playlist:
idx = self.create_playlist_tab(playlist) self.open_playlist(session, playlist)
playlist.mark_open()
session.commit()
self.playlist_section.tabPlaylist.setCurrentIndex(idx) def open_playlist(
self, session: Session, playlist: Playlists, is_template: bool = False
) -> None:
"""Open passed playlist"""
idx = self.create_playlist_tab(playlist, is_template)
playlist.mark_open()
session.commit()
self.playlist_section.tabPlaylist.setCurrentIndex(idx)
def open_songfacts_browser(self, title: str) -> None: def open_songfacts_browser(self, title: str) -> None:
"""Search Songfacts for title""" """Search Songfacts for title"""
@ -1699,24 +1897,45 @@ class Window(QMainWindow):
# Switch to correct tab # Switch to correct tab
if playlist_id != self.current.playlist_id: if playlist_id != self.current.playlist_id:
for idx in range(self.playlist_section.tabPlaylist.count()): open_idx = self.get_tab_index_for_playlist(playlist_id)
if ( if open_idx:
self.playlist_section.tabPlaylist.widget(idx).playlist_id self.playlist_section.tabPlaylist.setCurrentIndex(open_idx)
== playlist_id else:
): raise ApplicationError(
self.playlist_section.tabPlaylist.setCurrentIndex(idx) f"show_track() can't find current playlist tab {playlist_id=}"
break )
self.active_tab().scroll_to_top(playlist_track.row_number) self.active_tab().scroll_to_top(playlist_track.row_number)
def solicit_template_to_use(
self, session: Session, template_prmompt: Optional[str] = None
) -> Optional[int]:
"""
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))
with db.Session() as session:
for template in Playlists.get_all_templates(session):
template_name_id_list.append((template.name, template.id))
dlg = TemplateSelectorDialog(template_name_id_list, template_prmompt)
if not dlg.exec() or dlg.selected_id is None:
return None # User cancelled
return dlg.selected_id
def solicit_playlist_name( def solicit_playlist_name(
self, session: Session, default: str = "" self, session: Session, default: str = "", prompt: str = "Playlist name:"
) -> Optional[str]: ) -> Optional[str]:
"""Get name of new playlist from user""" """Get name of new playlist from user"""
dlg = QInputDialog(self) dlg = QInputDialog(self)
dlg.setInputMode(QInputDialog.InputMode.TextInput) dlg.setInputMode(QInputDialog.InputMode.TextInput)
dlg.setLabelText("Playlist name:") dlg.setLabelText(prompt)
while True: while True:
if default: if default:
dlg.setTextValue(default) dlg.setTextValue(default)

View File

@ -26,6 +26,7 @@ from PyQt6.QtGui import (
) )
# Third party imports # Third party imports
from sqlalchemy.orm.session import Session
import obswebsocket # type: ignore import obswebsocket # type: ignore
# import snoop # type: ignore # import snoop # type: ignore
@ -74,12 +75,14 @@ class PlaylistModel(QAbstractTableModel):
def __init__( def __init__(
self, self,
playlist_id: int, playlist_id: int,
is_template: bool,
*args: Optional[QObject], *args: Optional[QObject],
**kwargs: Optional[QObject], **kwargs: Optional[QObject],
) -> None: ) -> None:
log.debug("PlaylistModel.__init__()") log.debug("PlaylistModel.__init__()")
self.playlist_id = playlist_id self.playlist_id = playlist_id
self.is_template = is_template
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.playlist_rows: dict[int, RowAndTrack] = {} self.playlist_rows: dict[int, RowAndTrack] = {}
@ -223,6 +226,9 @@ class PlaylistModel(QAbstractTableModel):
if note_background: if note_background:
return QBrush(QColor(note_background)) return QBrush(QColor(note_background))
if self.is_template:
return QBrush(QColor(Config.COLOUR_TEMPLATE_ROW))
return QBrush() return QBrush()
def begin_reset_model(self, playlist_id: int) -> None: def begin_reset_model(self, playlist_id: int) -> None:
@ -772,7 +778,7 @@ class PlaylistModel(QAbstractTableModel):
return None return None
def load_data(self, session: db.session) -> None: def load_data(self, session: Session) -> None:
""" """
Same as refresh data, but only used when creating playslit. Same as refresh data, but only used when creating playslit.
Distinguishes profile time between initial load and other Distinguishes profile time between initial load and other
@ -1061,7 +1067,7 @@ class PlaylistModel(QAbstractTableModel):
# Update display # Update display
self.invalidate_row(track_sequence.previous.row_number) self.invalidate_row(track_sequence.previous.row_number)
def refresh_data(self, session: db.session) -> None: def refresh_data(self, session: Session) -> None:
""" """
Populate self.playlist_rows with playlist data Populate self.playlist_rows with playlist data

View File

@ -364,7 +364,7 @@ class PlaylistTab(QTableView):
Override closeEditor to enable play controls and update display. Override closeEditor to enable play controls and update display.
""" """
self.musicmuster.action_Clear_selection.setEnabled(True) self.musicmuster.enable_escape(True)
super(PlaylistTab, self).closeEditor(editor, hint) super(PlaylistTab, self).closeEditor(editor, hint)

102
menu.yaml Normal file
View File

@ -0,0 +1,102 @@
menus:
- title: "&File"
actions:
- text: "Save as Template"
handler: "save_as_template"
- text: "Manage Templates"
handler: "manage_templates"
- separator: true
- separator: true
- text: "Exit"
handler: "close"
- title: "&Playlist"
actions:
- text: "Open Playlist"
handler: "open_existing_playlist"
shortcut: "Ctrl+O"
- text: "New Playlist"
handler: "new_playlist_dynamic_submenu"
submenu: true
- text: "Close Playlist"
handler: "close_playlist_tab"
- text: "Rename Playlist"
handler: "rename_playlist"
- text: "Delete Playlist"
handler: "delete_playlist"
- separator: true
- text: "Insert Track"
handler: "insert_track"
shortcut: "Ctrl+T"
- text: "Select Track from Query"
handler: "query_dynamic_submenu"
submenu: true
- text: "Insert Section Header"
handler: "insert_header"
shortcut: "Ctrl+H"
- text: "Import Files"
handler: "import_files_wrapper"
shortcut: "Ctrl+Shift+I"
- separator: true
- text: "Mark for Moving"
handler: "mark_rows_for_moving"
shortcut: "Ctrl+C"
- text: "Paste"
handler: "paste_rows"
shortcut: "Ctrl+V"
- separator: true
- text: "Export Playlist"
handler: "export_playlist_tab"
- text: "Download CSV of Played Tracks"
handler: "download_played_tracks"
- separator: true
- text: "Select Duplicate Rows"
handler: "select_duplicate_rows"
- text: "Move Selected"
handler: "move_selected"
- text: "Move Unplayed"
handler: "move_unplayed"
- separator: true
- text: "Clear Selection"
handler: "clear_selection"
shortcut: "Esc"
store_reference: true # So we can enable/disable later
- title: "&Music"
actions:
- text: "Set Next"
handler: "set_selected_track_next"
shortcut: "Ctrl+N"
- text: "Play Next"
handler: "play_next"
shortcut: "Return"
- text: "Fade"
handler: "fade"
shortcut: "Ctrl+Z"
- text: "Stop"
handler: "stop"
shortcut: "Ctrl+Alt+S"
- text: "Resume"
handler: "resume"
shortcut: "Ctrl+R"
- text: "Skip to Next"
handler: "play_next"
shortcut: "Ctrl+Alt+Return"
- separator: true
- text: "Search"
handler: "search_playlist"
shortcut: "/"
- text: "Search Title in Wikipedia"
handler: "lookup_row_in_wikipedia"
shortcut: "Ctrl+W"
- text: "Search Title in Songfacts"
handler: "lookup_row_in_songfacts"
shortcut: "Ctrl+S"
- title: "Help"
actions:
- text: "About"
handler: "about"
- text: "Debug"
handler: "debug"

View File

@ -0,0 +1,58 @@
"""add favouirit to playlists
Revision ID: 04df697e40cd
Revises: 33c04e3c12c8
Create Date: 2025-02-22 20:20:45.030024
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = '04df697e40cd'
down_revision = '33c04e3c12c8'
branch_labels = None
depends_on = None
def upgrade(engine_name: str) -> None:
globals()["upgrade_%s" % engine_name]()
def downgrade(engine_name: str) -> None:
globals()["downgrade_%s" % engine_name]()
def upgrade_() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('notecolours', schema=None) as batch_op:
batch_op.add_column(sa.Column('strip_substring', sa.Boolean(), nullable=False))
batch_op.create_index(batch_op.f('ix_notecolours_substring'), ['substring'], unique=False)
with op.batch_alter_table('playlist_rows', schema=None) as batch_op:
batch_op.drop_constraint('playlist_rows_ibfk_1', type_='foreignkey')
with op.batch_alter_table('playlists', schema=None) as batch_op:
batch_op.add_column(sa.Column('favourite', sa.Boolean(), nullable=False))
# ### end Alembic commands ###
def downgrade_() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('playlists', schema=None) as batch_op:
batch_op.drop_column('favourite')
with op.batch_alter_table('playlist_rows', schema=None) as batch_op:
batch_op.create_foreign_key('playlist_rows_ibfk_1', 'tracks', ['track_id'], ['id'])
with op.batch_alter_table('notecolours', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_notecolours_substring'))
batch_op.drop_column('strip_substring')
# ### end Alembic commands ###