Compare commits

...

2 Commits

Author SHA1 Message Date
Keith Edmunds
9e1995be68 Create Queries table; smarten Alembic
Don't have db.create_all() run when Alembic runs because then it fails
to detect new tables.
2025-02-24 20:50:44 +00:00
Keith Edmunds
2abb672142 Cascade deletes for tracks→playdates 2025-02-23 21:06:42 +00:00
6 changed files with 154 additions and 51 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,6 +46,33 @@ def singleton(cls):
return wrapper_singleton return wrapper_singleton
class ApplicationError(Exception):
"""
Custom exception
"""
pass
class AudioMetadata(NamedTuple):
start_gap: int = 0
silence_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): class FileErrors(NamedTuple):
path: str path: str
error: str error: str
@ -82,20 +89,6 @@ class Filter:
duration_unit: str = "minutes" duration_unit: str = "minutes"
class ApplicationError(Exception):
"""
Custom exception
"""
pass
class AudioMetadata(NamedTuple):
start_gap: int = 0
silence_at: int = 0
fade_at: int = 0
@singleton @singleton
@dataclass @dataclass
class MusicMusterSignals(QObject): class MusicMusterSignals(QObject):
@ -142,6 +135,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

@ -1,4 +1,5 @@
# Standard library imports # Standard library imports
import sys
# PyQt imports # PyQt imports
@ -8,6 +9,12 @@ from alchemical import Alchemical # type:ignore
# App imports # App imports
def is_alembic_command():
# Define keywords that indicate Alembic is being invoked.
alembic_keywords = {'alembic', 'revision', 'upgrade', 'downgrade', 'history', 'current'}
return any(arg in alembic_keywords for arg in sys.argv)
class DatabaseManager: class DatabaseManager:
""" """
Singleton class to ensure we only ever have one db object Singleton class to ensure we only ever have one db object
@ -18,7 +25,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() if not is_alembic_command():
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,7 @@
# Standard library imports # Standard library imports
from typing import Optional from typing import Optional
import datetime as dt import datetime as dt
import json
# PyQt imports # PyQt imports
@ -18,8 +19,30 @@ from sqlalchemy.orm import (
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, dialect):
"""Convert Python dictionary to JSON string before saving."""
if value is None:
return None
return json.dumps(value)
def process_result_value(self, value, dialect):
"""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 +71,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 +127,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 +151,13 @@ 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: Mapped[Filter] = mapped_column(JSONEncodedDict, nullable=True)
String(2048), index=False, default="", nullable=False
)
favourite: Mapped[bool] = mapped_column( favourite: Mapped[bool] = mapped_column(
Boolean, nullable=False, index=False, default=False Boolean, nullable=False, index=False, default=False
) )
def __repr__(self) -> str: def __repr__(self) -> str:
return f"<Queries(id={self.id}, name={self.name}, sql={self.sql}>" return f"<Queries(id={self.id}, name={self.name}, filter={self.filter}>"
class SettingsTable(Model): class SettingsTable(Model):

View File

@ -253,10 +253,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 +605,19 @@ 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()
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

@ -751,10 +751,11 @@ 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),
}
] ]
templates = Playlists.get_favourite_templates(session) templates = Playlists.get_favourite_templates(session)
for template in templates: for template in templates:
@ -2199,7 +2200,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,55 @@
"""create queries table
Revision ID: 335cae31045f
Revises: f14dd379850f
Create Date: 2025-02-24 20:43:41.534922
"""
from alembic import op
import sqlalchemy as sa
import dbtables
# revision identifiers, used by Alembic.
revision = '335cae31045f'
down_revision = 'f14dd379850f'
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', dbtables.JSONEncodedDict(), nullable=True),
sa.Column('favourite', sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
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'])
op.drop_table('queries')
# ### end Alembic commands ###