Compare commits

...

3 Commits

Author SHA1 Message Date
Keith Edmunds
b4f5d92f5d WIP: query management 2025-02-26 13:58:13 +00:00
Keith Edmunds
985629446a Create queries table 2025-02-26 13:34:10 +00:00
Keith Edmunds
64ccb485b5 Fix playdates cascade deletes 2025-02-26 13:29:42 +00:00
8 changed files with 557 additions and 188 deletions

View File

@ -23,27 +23,7 @@ from PyQt6.QtWidgets import (
# App imports # App imports
class Col(Enum): # Define singleton first as it's needed below
START_GAP = 0
TITLE = auto()
ARTIST = auto()
INTRO = auto()
DURATION = auto()
START_TIME = auto()
END_TIME = auto()
LAST_PLAYED = auto()
BITRATE = auto()
NOTE = auto()
class QueryCol(Enum):
TITLE = 0
ARTIST = auto()
DURATION = auto()
LAST_PLAYED = auto()
BITRATE = auto()
def singleton(cls): def singleton(cls):
""" """
Make a class a Singleton class (see Make a class a Singleton class (see
@ -66,22 +46,6 @@ def singleton(cls):
return wrapper_singleton return wrapper_singleton
class FileErrors(NamedTuple):
path: str
error: str
@dataclass
class Filter:
path_type: str = "contains"
path: Optional[str] = None
last_played_number: Optional[int] = None
last_played_unit: str = "years"
duration_type: str = "longer than"
duration_number: int = 0
duration_unit: str = "minutes"
class ApplicationError(Exception): class ApplicationError(Exception):
""" """
Custom exception Custom exception
@ -96,6 +60,36 @@ class AudioMetadata(NamedTuple):
fade_at: int = 0 fade_at: int = 0
class Col(Enum):
START_GAP = 0
TITLE = auto()
ARTIST = auto()
INTRO = auto()
DURATION = auto()
START_TIME = auto()
END_TIME = auto()
LAST_PLAYED = auto()
BITRATE = auto()
NOTE = auto()
class FileErrors(NamedTuple):
path: str
error: str
@dataclass
class Filter:
path_type: str = "contains"
path: Optional[str] = None
last_played_number: Optional[int] = None
last_played_type: str = "before"
last_played_unit: str = "years"
duration_type: str = "longer than"
duration_number: int = 0
duration_unit: str = "minutes"
@singleton @singleton
@dataclass @dataclass
class MusicMusterSignals(QObject): class MusicMusterSignals(QObject):
@ -142,6 +136,14 @@ class PlaylistStyle(QProxyStyle):
super().drawPrimitive(element, option, painter, widget) super().drawPrimitive(element, option, painter, widget)
class QueryCol(Enum):
TITLE = 0
ARTIST = auto()
DURATION = auto()
LAST_PLAYED = auto()
BITRATE = auto()
class Tags(NamedTuple): class Tags(NamedTuple):
artist: str = "" artist: str = ""
title: str = "" title: str = ""

View File

@ -18,7 +18,8 @@ class DatabaseManager:
def __init__(self, database_url: str, **kwargs: dict) -> None: def __init__(self, database_url: str, **kwargs: dict) -> None:
if DatabaseManager.__instance is None: if DatabaseManager.__instance is None:
self.db = Alchemical(database_url, **kwargs) self.db = Alchemical(database_url, **kwargs)
self.db.create_all() # Database managed by Alembic so no create_all() required
# self.db.create_all()
DatabaseManager.__instance = self DatabaseManager.__instance = self
else: else:
raise Exception("Attempted to create a second DatabaseManager instance") raise Exception("Attempted to create a second DatabaseManager instance")

View File

@ -1,6 +1,8 @@
# Standard library imports # Standard library imports
from typing import Optional from typing import Optional
from dataclasses import asdict
import datetime as dt import datetime as dt
import json
# PyQt imports # PyQt imports
@ -13,13 +15,37 @@ from sqlalchemy import (
String, String,
) )
from sqlalchemy.ext.associationproxy import association_proxy from sqlalchemy.ext.associationproxy import association_proxy
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.engine.interfaces import Dialect
from sqlalchemy.orm import ( from sqlalchemy.orm import (
Mapped, Mapped,
mapped_column, mapped_column,
relationship, relationship,
) )
from sqlalchemy.types import TypeDecorator, TEXT
# App imports # App imports
from classes import Filter
class JSONEncodedDict(TypeDecorator):
"""
Custom JSON Type for MariaDB (since native JSON type is just LONGTEXT)
"""
impl = TEXT
def process_bind_param(self, value: dict | None, dialect: Dialect) -> str | None:
"""Convert Python dictionary to JSON string before saving."""
if value is None:
return None
return json.dumps(value, default=lambda o: o.__dict__)
def process_result_value(self, value: str | None, dialect: Dialect) -> dict | None:
"""Convert JSON string back to Python dictionary after retrieval."""
if value is None:
return None
return json.loads(value)
# Database classes # Database classes
@ -48,7 +74,7 @@ class PlaydatesTable(Model):
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
lastplayed: Mapped[dt.datetime] = mapped_column(index=True) lastplayed: Mapped[dt.datetime] = mapped_column(index=True)
track_id: Mapped[int] = mapped_column(ForeignKey("tracks.id")) track_id: Mapped[int] = mapped_column(ForeignKey("tracks.id", ondelete="CASCADE"))
track: Mapped["TracksTable"] = relationship( track: Mapped["TracksTable"] = relationship(
"TracksTable", "TracksTable",
back_populates="playdates", back_populates="playdates",
@ -104,7 +130,9 @@ class PlaylistRowsTable(Model):
) )
playlist: Mapped[PlaylistsTable] = relationship(back_populates="rows") playlist: Mapped[PlaylistsTable] = relationship(back_populates="rows")
track_id: Mapped[Optional[int]] = mapped_column(ForeignKey("tracks.id", ondelete="CASCADE")) track_id: Mapped[Optional[int]] = mapped_column(
ForeignKey("tracks.id", ondelete="CASCADE")
)
track: Mapped["TracksTable"] = relationship( track: Mapped["TracksTable"] = relationship(
"TracksTable", "TracksTable",
back_populates="playlistrows", back_populates="playlistrows",
@ -126,15 +154,24 @@ class QueriesTable(Model):
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(128), nullable=False) name: Mapped[str] = mapped_column(String(128), nullable=False)
sql: Mapped[str] = mapped_column( _filter_data: Mapped[dict | None] = mapped_column("filter_data", JSONEncodedDict, nullable=True)
String(2048), index=False, default="", nullable=False favourite: Mapped[bool] = mapped_column(Boolean, nullable=False, index=False, default=False)
)
favourite: Mapped[bool] = mapped_column( def _get_filter(self) -> Filter:
Boolean, nullable=False, index=False, default=False """Convert stored JSON dictionary to a Filter object."""
) if isinstance(self._filter_data, dict):
return Filter(**self._filter_data)
return Filter() # Default object if None or invalid data
def _set_filter(self, value: Filter | None) -> None:
"""Convert a Filter object to JSON before storing."""
self._filter_data = asdict(value) if isinstance(value, Filter) else None
# Single definition of `filter`
filter = property(_get_filter, _set_filter)
def __repr__(self) -> str: def __repr__(self) -> str:
return f"<Queries(id={self.id}, name={self.name}, sql={self.sql}>" return f"<QueriesTable(id={self.id}, name={self.name}, filter={self.filter})>"
class SettingsTable(Model): class SettingsTable(Model):

View File

@ -6,6 +6,8 @@ menus:
- text: "Manage Templates" - text: "Manage Templates"
handler: "manage_templates" handler: "manage_templates"
- separator: true - separator: true
- text: "Manage Queries"
handler: "manage_queries"
- separator: true - separator: true
- text: "Exit" - text: "Exit"
handler: "close" handler: "close"

View File

@ -221,14 +221,6 @@ class Playlists(dbtables.PlaylistsTable):
self.open = False self.open = False
session.commit() session.commit()
def delete(self, session: Session) -> None:
"""
Delete playlist
"""
session.execute(delete(Playlists).where(Playlists.id == self.id))
session.commit()
@classmethod @classmethod
def get_all(cls, session: Session) -> Sequence["Playlists"]: def get_all(cls, session: Session) -> Sequence["Playlists"]:
"""Returns a list of all playlists ordered by last use""" """Returns a list of all playlists ordered by last use"""
@ -253,10 +245,7 @@ class Playlists(dbtables.PlaylistsTable):
return session.scalars( return session.scalars(
select(cls) select(cls)
.where( .where(cls.is_template.is_(True), cls.favourite.is_(True))
cls.is_template.is_(True),
cls.favourite.is_(True)
)
.order_by(cls.name) .order_by(cls.name)
).all() ).all()
@ -608,6 +597,25 @@ class PlaylistRows(dbtables.PlaylistRowsTable):
session.connection().execute(stmt, sqla_map) session.connection().execute(stmt, sqla_map)
class Queries(dbtables.QueriesTable):
def __init__(
self, session: Session, name: str, filter: dbtables.Filter, favourite: bool = False
) -> None:
"""Create new query"""
self.name = name
self.filter = filter
self.favourite = favourite
session.add(self)
session.commit()
@classmethod
def get_all_queries(cls, session: Session) -> Sequence["Queries"]:
"""Returns a list of all queries ordered by name"""
return session.scalars(select(cls).order_by(cls.name)).all()
class Settings(dbtables.SettingsTable): class Settings(dbtables.SettingsTable):
def __init__(self, session: Session, name: str) -> None: def __init__(self, session: Session, name: str) -> None:
self.name = name self.name = name

View File

@ -46,6 +46,7 @@ from PyQt6.QtWidgets import (
QMenu, QMenu,
QMessageBox, QMessageBox,
QPushButton, QPushButton,
QSpinBox,
QTableWidget, QTableWidget,
QTableWidgetItem, QTableWidgetItem,
QVBoxLayout, QVBoxLayout,
@ -60,6 +61,7 @@ import stackprinter # type: ignore
# App imports # App imports
from classes import ( from classes import (
ApplicationError, ApplicationError,
Filter,
MusicMusterSignals, MusicMusterSignals,
TrackInfo, TrackInfo,
) )
@ -68,7 +70,7 @@ from dialogs import TrackSelectDialog
from file_importer import FileImporter from file_importer import FileImporter
from helpers import file_is_unreadable from helpers import file_is_unreadable
from log import log from log import log
from models import db, Playdates, PlaylistRows, Playlists, Settings, Tracks from models import db, Playdates, PlaylistRows, Playlists, Queries, Settings, Tracks
from music_manager import RowAndTrack, track_sequence from music_manager import RowAndTrack, track_sequence
from playlistmodel import PlaylistModel, PlaylistProxyModel from playlistmodel import PlaylistModel, PlaylistProxyModel
from playlists import PlaylistTab from playlists import PlaylistTab
@ -166,6 +168,126 @@ class EditDeleteDialog(QDialog):
self.reject() 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(["contains", "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(["before", "never"])
for idx in range(self.last_played_combo.count()):
if self.last_played_combo.itemText(idx) == filter.last_played_type:
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(["years", "months", "weeks", "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(["longer than", "shorter than"])
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(["minutes", "seconds"])
self.duration_unit.setCurrentText("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.accept)
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)
@dataclass @dataclass
class ItemlistItem: class ItemlistItem:
id: int id: int
@ -724,6 +846,10 @@ class Window(QMainWindow):
# Dynamically call the correct function # Dynamically call the correct function
items = getattr(self, f"get_{key}_items")() items = getattr(self, f"get_{key}_items")()
for item in items: for item in items:
# Check for separator
if "separator" in item and item["separator"] == "separator":
submenu.addSeparator()
continue
action = QAction(item["text"], self) action = QAction(item["text"], self)
# Extract handler and arguments # Extract handler and arguments
@ -751,10 +877,14 @@ class Window(QMainWindow):
with db.Session() as session: with db.Session() as session:
submenu_items: list[dict[str, str | tuple[Session, int]]] = [ submenu_items: list[dict[str, str | tuple[Session, int]]] = [
{"text": "Show all", {
"handler": "create_playlist_from_template", "text": "Show all",
"args": (session, 0) "handler": "create_playlist_from_template",
} "args": (session, 0),
},
{
"separator": "separator",
}
] ]
templates = Playlists.get_favourite_templates(session) templates = Playlists.get_favourite_templates(session)
for template in templates: for template in templates:
@ -835,7 +965,7 @@ class Window(QMainWindow):
else: else:
template_id = selected_template_id template_id = selected_template_id
playlist_name = self.solicit_playlist_name(session) playlist_name = self.solicit_name(session)
if not playlist_name: if not playlist_name:
return return
@ -857,7 +987,7 @@ class Window(QMainWindow):
f"Delete playlist '{playlist.name}': " "Are you sure?", f"Delete playlist '{playlist.name}': " "Are you sure?",
): ):
if self.close_playlist_tab(): if self.close_playlist_tab():
playlist.delete(session) session.delete(playlist)
session.commit() session.commit()
else: else:
log.error("Failed to retrieve playlist") log.error("Failed to retrieve playlist")
@ -900,7 +1030,7 @@ class Window(QMainWindow):
session.commit() session.commit()
helpers.show_OK("Template", "Template saved", self) helpers.show_OK("Template", "Template saved", self)
def solicit_playlist_name( def solicit_name(
self, session: Session, default: str = "", prompt: str = "Playlist name:" 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"""
@ -948,6 +1078,216 @@ class Window(QMainWindow):
return dlg.selected_id return dlg.selected_id
# # # # # # # # # # Manage templates and queries # # # # # # # # # #
def manage_queries(self) -> None:
"""
Delete / edit queries
"""
# Define callbacks to handle management options
def delete(query_id: int) -> None:
"""delete query"""
query = session.get(Queries, 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?",
):
log.info(f"manage_queries: delete {query=}")
session.delete(query)
session.commit()
def edit(query_id: int) -> None:
"""Edit query"""
query = session.get(Queries, query_id)
if not query:
raise ApplicationError(
f"manage_template.edit({query_id=}) can't load query"
)
import pdb; pdb.set_trace()
dlg = FilterDialog(query.name, query.filter)
dlg.show()
def favourite(query_id: int, favourite: bool) -> None:
"""Mark query as (not) favourite"""
query = session.get(Queries, query_id)
query.favourite = favourite
session.commit()
def new_item() -> None:
"""Create new query"""
# TODO: create query
print("create query")
def rename(query_id: int) -> Optional[str]:
"""rename query"""
query = session.get(Queries, query_id)
if not query:
raise ApplicationError(
f"manage_template.delete({query_id=}) can't load query"
)
new_name = self.solicit_name(session, query.name, prompt="New query name")
if new_name:
query.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 queries
callbacks = ItemlistManagerCallbacks(
delete=delete,
edit=edit,
favourite=favourite,
new_item=new_item,
rename=rename,
)
# Build a list of queries
query_list: list[ItemlistItem] = []
with db.Session() as session:
for query in Queries.get_all_queries(session):
query_list.append(
ItemlistItem(
name=query.name, id=query.id, favourite=query.favourite
)
)
# We need to retain a reference to the dialog box to stop it
# going out of scope and being garbage-collected.
self.dlg = ItemlistManager(query_list, callbacks)
self.dlg.show()
def manage_templates(self) -> None:
"""
Delete / edit templates
"""
# Define callbacks to handle management options
def delete(template_id: int) -> None:
"""delete template"""
template = session.get(Playlists, 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.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=}")
session.delete(template)
session.commit()
def edit(template_id: int) -> None:
"""Edit template"""
template = session.get(Playlists, 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._open_playlist(template, is_template=True)
def favourite(template_id: int, favourite: bool) -> None:
"""Mark template as (not) favourite"""
template = session.get(Playlists, template_id)
template.favourite = favourite
session.commit()
def new_item() -> None:
"""Create new template"""
# Get base template
template_id = self.solicit_template_to_use(
session, template_prompt="New template based upon:"
)
if template_id is None:
return
# Get new template name
name = self.solicit_name(
session, default="", prompt="New template name:"
)
if not name:
return
# Create playlist for template and mark is as a template
template = self._create_playlist(session, name, template_id)
template.is_template = True
session.commit()
# Open it for editing
self._open_playlist(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_template.delete({template_id=}) can't load template"
)
new_name = self.solicit_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:
for template in Playlists.get_all_templates(session):
template_list.append(
ItemlistItem(
name=template.name, id=template.id, favourite=template.favourite
)
)
# We need to retain a reference to the dialog box to stop it
# going out of scope and being garbage-collected.
self.dlg = ItemlistManager(template_list, callbacks)
self.dlg.show()
# # # # # # # # # # Miscellaneous functions # # # # # # # # # # # # # # # # # # # # Miscellaneous functions # # # # # # # # # #
def select_duplicate_rows(self) -> None: def select_duplicate_rows(self) -> None:
@ -1341,125 +1681,6 @@ class Window(QMainWindow):
self.signals.search_wikipedia_signal.emit(track_info.title) self.signals.search_wikipedia_signal.emit(track_info.title)
def manage_templates(self) -> None:
"""
Delete / edit templates
"""
# Define callbacks to handle management options
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
self._open_playlist(template, is_template=True)
def favourite(template_id: int, favourite: bool) -> None:
"""Mark template as (not) favourite"""
template = session.get(Playlists, template_id)
template.favourite = favourite
session.commit()
def new_item() -> None:
"""Create new template"""
# Get base template
template_id = self.solicit_template_to_use(
session, template_prompt="New template based upon:"
)
if template_id is None:
return
# Get new template name
name = self.solicit_playlist_name(
session, default="", prompt="New template name:"
)
if not name:
return
# Create playlist for template and mark is as a template
template = self._create_playlist(session, name, template_id)
template.is_template = True
session.commit()
# Open it for editing
self._open_playlist(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:
for template in Playlists.get_all_templates(session):
template_list.append(
ItemlistItem(
name=template.name, id=template.id, favourite=template.favourite
)
)
# We need to retain a reference to the dialog box to stop it
# going out of scope and being garbage-collected.
self.dlg = ItemlistManager(template_list, callbacks)
self.dlg.show()
def mark_rows_for_moving(self) -> None: def mark_rows_for_moving(self) -> None:
""" """
Cut rows ready for pasting. Cut rows ready for pasting.
@ -1778,7 +1999,7 @@ class Window(QMainWindow):
playlist_id = self.current.playlist_id playlist_id = self.current.playlist_id
playlist = session.get(Playlists, playlist_id) playlist = session.get(Playlists, playlist_id)
if playlist: if playlist:
new_name = self.solicit_playlist_name(session, playlist.name) new_name = self.solicit_name(session, playlist.name)
if new_name: if new_name:
playlist.rename(session, new_name) playlist.rename(session, new_name)
idx = self.tabBar.currentIndex() idx = self.tabBar.currentIndex()
@ -2199,7 +2420,12 @@ class Window(QMainWindow):
self.playlist_section.tabPlaylist.setTabIcon( self.playlist_section.tabPlaylist.setTabIcon(
idx, QIcon(Config.PLAYLIST_ICON_CURRENT) idx, QIcon(Config.PLAYLIST_ICON_CURRENT)
) )
elif self.playlist_section.tabPlaylist.widget(idx).model().sourceModel().is_template: elif (
self.playlist_section.tabPlaylist.widget(idx)
.model()
.sourceModel()
.is_template
):
self.playlist_section.tabPlaylist.setTabIcon( self.playlist_section.tabPlaylist.setTabIcon(
idx, QIcon(Config.PLAYLIST_ICON_TEMPLATE) idx, QIcon(Config.PLAYLIST_ICON_TEMPLATE)
) )

View File

@ -0,0 +1,47 @@
"""create queries table
Revision ID: 4fc2a9a82ab0
Revises: ab475332d873
Create Date: 2025-02-26 13:13:25.118489
"""
from alembic import op
import sqlalchemy as sa
import dbtables
# revision identifiers, used by Alembic.
revision = '4fc2a9a82ab0'
down_revision = 'ab475332d873'
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! ###
op.create_table('queries',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('name', sa.String(length=128), nullable=False),
sa.Column('filter_data', dbtables.JSONEncodedDict(), nullable=True),
sa.Column('favourite', sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade_() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('queries')
# ### end Alembic commands ###

View File

@ -0,0 +1,46 @@
"""fix playlist cascades
Revision ID: ab475332d873
Revises: 04df697e40cd
Create Date: 2025-02-26 13:11:15.417278
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'ab475332d873'
down_revision = '04df697e40cd'
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('playdates', schema=None) as batch_op:
batch_op.drop_constraint('fk_playdates_track_id_tracks', type_='foreignkey')
batch_op.create_foreign_key(None, 'tracks', ['track_id'], ['id'], ondelete='CASCADE')
# ### end Alembic commands ###
def downgrade_() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('playdates', schema=None) as batch_op:
batch_op.drop_constraint(None, type_='foreignkey')
batch_op.create_foreign_key('fk_playdates_track_id_tracks', 'tracks', ['track_id'], ['id'])
# ### end Alembic commands ###