Compare commits

..

3 Commits

Author SHA1 Message Date
Keith Edmunds
9ac2911a55 Typing and mypy fixes 2023-10-15 21:04:54 +01:00
Keith Edmunds
3513c32a62 Speed increases, more typing, cleanup
Pull all playlist row info in one database query when loading a
playlist.

Fixup some type hints in models.

Comment out stackprinter calls - they mostly get in the way
interactively.
2023-10-15 19:04:58 +01:00
Keith Edmunds
ae87ac82ba Migrate model to SQLAlchemy 2.0 DeclarativeBase 2023-10-15 09:51:02 +01:00
10 changed files with 932 additions and 805 deletions

View File

@ -30,10 +30,10 @@ else:
engine = create_engine( engine = create_engine(
MYSQL_CONNECT, MYSQL_CONNECT,
encoding="utf-8",
echo=Config.DISPLAY_SQL, echo=Config.DISPLAY_SQL,
pool_pre_ping=True, pool_pre_ping=True,
future=True, future=True,
connect_args={"charset": "utf8mb4"},
) )

View File

@ -75,8 +75,8 @@ def log_uncaught_exceptions(_ex_cls, ex, tb):
print("\033[1;31;47m") print("\033[1;31;47m")
logging.critical(''.join(traceback.format_tb(tb))) logging.critical(''.join(traceback.format_tb(tb)))
print("\033[1;37;40m") print("\033[1;37;40m")
print(stackprinter.format(ex, show_vals="all", add_summary=True, # print(stackprinter.format(ex, show_vals="all", add_summary=True,
style="darkbg")) # style="darkbg"))
if os.environ["MM_ENV"] == "PRODUCTION": if os.environ["MM_ENV"] == "PRODUCTION":
msg = stackprinter.format(ex) msg = stackprinter.format(ex)
send_mail(Config.ERRORS_TO, Config.ERRORS_FROM, send_mail(Config.ERRORS_TO, Config.ERRORS_FROM,

View File

@ -6,27 +6,27 @@ from config import Config
from dbconfig import scoped_session from dbconfig import scoped_session
from datetime import datetime from datetime import datetime
from typing import List, Optional from typing import List, Optional, Sequence
from sqlalchemy.ext.associationproxy import association_proxy from sqlalchemy.ext.associationproxy import association_proxy
from sqlalchemy import ( from sqlalchemy import (
Boolean, Boolean,
Column,
DateTime, DateTime,
delete, delete,
Float,
ForeignKey, ForeignKey,
func, func,
Integer,
select, select,
String, String,
update, update,
) )
from sqlalchemy.orm import ( from sqlalchemy.orm import (
declarative_base, DeclarativeBase,
joinedload, joinedload,
lazyload,
Mapped,
mapped_column,
relationship, relationship,
) )
from sqlalchemy.orm.exc import ( from sqlalchemy.orm.exc import (
@ -37,19 +37,21 @@ from sqlalchemy.exc import (
) )
from log import log from log import log
Base = declarative_base()
class Base(DeclarativeBase):
pass
# Database classes # Database classes
class Carts(Base): class Carts(Base):
__tablename__ = "carts" __tablename__ = "carts"
id: int = Column(Integer, primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
cart_number: int = Column(Integer, nullable=False, unique=True) cart_number: Mapped[int] = mapped_column(unique=True)
name = Column(String(256), index=True) name: Mapped[str] = mapped_column(String(256), index=True)
duration = Column(Integer, index=True) duration: Mapped[int] = mapped_column(index=True)
path = Column(String(2048), index=False) path: Mapped[str] = mapped_column(String(2048), index=False)
enabled: bool = Column(Boolean, default=False, nullable=False) enabled: Mapped[bool] = mapped_column(default=False)
def __repr__(self) -> str: def __repr__(self) -> str:
return ( return (
@ -81,13 +83,13 @@ class Carts(Base):
class NoteColours(Base): class NoteColours(Base):
__tablename__ = "notecolours" __tablename__ = "notecolours"
id = Column(Integer, primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
substring = Column(String(256), index=False) substring: Mapped[str] = mapped_column(String(256), index=False)
colour = Column(String(21), index=False) colour: Mapped[str] = mapped_column(String(21), index=False)
enabled = Column(Boolean, default=True, index=True) enabled: Mapped[bool] = mapped_column(default=True, index=True)
is_regex = Column(Boolean, default=False, index=False) is_regex: Mapped[bool] = mapped_column(default=False, index=False)
is_casesensitive = Column(Boolean, default=False, index=False) is_casesensitive: Mapped[bool] = mapped_column(default=False, index=False)
order = Column(Integer, index=True) order: Mapped[Optional[int]] = mapped_column(index=True)
def __repr__(self) -> str: def __repr__(self) -> str:
return ( return (
@ -134,10 +136,10 @@ class NoteColours(Base):
class Playdates(Base): class Playdates(Base):
__tablename__ = "playdates" __tablename__ = "playdates"
id: int = Column(Integer, primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
lastplayed: datetime = Column(DateTime, index=True) lastplayed: Mapped[datetime] = mapped_column(index=True)
track_id = Column(Integer, ForeignKey("tracks.id")) track_id: Mapped[int] = mapped_column(ForeignKey("tracks.id"))
track: "Tracks" = relationship("Tracks", back_populates="playdates") track: Mapped["Tracks"] = relationship("Tracks", back_populates="playdates")
def __repr__(self) -> str: def __repr__(self) -> str:
return ( return (
@ -170,7 +172,7 @@ class Playdates(Base):
return Config.EPOCH return Config.EPOCH
@staticmethod @staticmethod
def played_after(session: scoped_session, since: datetime) -> List["Playdates"]: def played_after(session: scoped_session, since: datetime) -> Sequence["Playdates"]:
"""Return a list of Playdates objects since passed time""" """Return a list of Playdates objects since passed time"""
return ( return (
@ -191,16 +193,13 @@ class Playlists(Base):
__tablename__ = "playlists" __tablename__ = "playlists"
id = Column(Integer, primary_key=True, autoincrement=True, nullable=False) id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
name: str = Column(String(32), nullable=False, unique=True) name: Mapped[str] = mapped_column(String(32), unique=True)
last_used = Column(DateTime, default=None, nullable=True) last_used: Mapped[Optional[datetime]] = mapped_column(DateTime, default=None)
tab = Column(Integer, default=None, nullable=True, unique=True) tab: Mapped[Optional[int]] = mapped_column(default=None, unique=True)
# TODO sort_column is unused is_template: Mapped[bool] = mapped_column(default=False)
sort_column = Column(Integer, default=None, nullable=True, unique=False) deleted: Mapped[bool] = mapped_column(default=False)
is_template: bool = Column(Boolean, default=False, nullable=False) rows: Mapped[List["PlaylistRows"]] = relationship(
query = Column(String(256), default=None, nullable=True, unique=False)
deleted: bool = Column(Boolean, default=False, nullable=False)
rows: List["PlaylistRows"] = relationship(
"PlaylistRows", "PlaylistRows",
back_populates="playlist", back_populates="playlist",
cascade="all, delete-orphan", cascade="all, delete-orphan",
@ -257,7 +256,7 @@ class Playlists(Base):
session.flush() session.flush()
@classmethod @classmethod
def get_all(cls, session: scoped_session) -> List["Playlists"]: def get_all(cls, session: scoped_session) -> Sequence["Playlists"]:
"""Returns a list of all playlists ordered by last use""" """Returns a list of all playlists ordered by last use"""
return ( return (
@ -271,7 +270,7 @@ class Playlists(Base):
) )
@classmethod @classmethod
def get_all_templates(cls, session: scoped_session) -> List["Playlists"]: def get_all_templates(cls, session: scoped_session) -> Sequence["Playlists"]:
"""Returns a list of all templates ordered by name""" """Returns a list of all templates ordered by name"""
return ( return (
@ -283,7 +282,7 @@ class Playlists(Base):
) )
@classmethod @classmethod
def get_closed(cls, session: scoped_session) -> List["Playlists"]: def get_closed(cls, session: scoped_session) -> Sequence["Playlists"]:
"""Returns a list of all closed playlists ordered by last use""" """Returns a list of all closed playlists ordered by last use"""
return ( return (
@ -301,7 +300,7 @@ class Playlists(Base):
) )
@classmethod @classmethod
def get_open(cls, session: scoped_session) -> List[Optional["Playlists"]]: def get_open(cls, session: scoped_session) -> Sequence[Optional["Playlists"]]:
""" """
Return a list of loaded playlists ordered by tab order. Return a list of loaded playlists ordered by tab order.
""" """
@ -359,14 +358,17 @@ class Playlists(Base):
class PlaylistRows(Base): class PlaylistRows(Base):
__tablename__ = "playlist_rows" __tablename__ = "playlist_rows"
id: int = Column(Integer, primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
plr_rownum: int = Column(Integer, nullable=False) plr_rownum: Mapped[int]
note: str = Column(String(2048), index=False, default="", nullable=False) note: Mapped[str] = mapped_column(String(2048), index=False, default="", nullable=False)
playlist_id: int = Column(Integer, ForeignKey("playlists.id"), nullable=False) playlist_id: Mapped[int] = mapped_column(ForeignKey("playlists.id"))
playlist: Playlists = relationship(Playlists, back_populates="rows") playlist: Mapped[Playlists] = relationship(back_populates="rows")
track_id = Column(Integer, ForeignKey("tracks.id"), nullable=True) track_id: Mapped[Optional[int]] = mapped_column(ForeignKey("tracks.id"))
track: "Tracks" = relationship("Tracks", back_populates="playlistrows") track: Mapped["Tracks"] = relationship(
played: bool = Column(Boolean, nullable=False, index=False, default=False) "Tracks",
back_populates="playlistrows",
)
played: Mapped[bool] = mapped_column(Boolean, nullable=False, index=False, default=False)
def __repr__(self) -> str: def __repr__(self) -> str:
return ( return (
@ -433,6 +435,23 @@ class PlaylistRows(Base):
) )
session.flush() session.flush()
@classmethod
def deep_rows(cls, session: scoped_session, playlist_id: int) -> Sequence["PlaylistRows"]:
"""
Return a list of playlist rows that include full track and lastplayed data for
given playlist_id., Sequence
"""
stmt = (
select(PlaylistRows)
.options(joinedload(cls.track))
.where(PlaylistRows.playlist_id == playlist_id)
.order_by(PlaylistRows.plr_rownum)
# .options(joinedload(Tracks.playdates))
)
return session.scalars(stmt).unique().all()
@staticmethod @staticmethod
def fixup_rownumbers(session: scoped_session, playlist_id: int) -> None: def fixup_rownumbers(session: scoped_session, playlist_id: int) -> None:
""" """
@ -458,7 +477,7 @@ class PlaylistRows(Base):
@classmethod @classmethod
def plrids_to_plrs( def plrids_to_plrs(
cls, session: scoped_session, playlist_id: int, plr_ids: List[int] cls, session: scoped_session, playlist_id: int, plr_ids: List[int]
) -> List["PlaylistRows"]: ) -> Sequence["PlaylistRows"]:
""" """
Take a list of PlaylistRows ids and return a list of corresponding Take a list of PlaylistRows ids and return a list of corresponding
PlaylistRows objects PlaylistRows objects
@ -504,7 +523,7 @@ class PlaylistRows(Base):
@classmethod @classmethod
def get_played_rows( def get_played_rows(
cls, session: scoped_session, playlist_id: int cls, session: scoped_session, playlist_id: int
) -> List["PlaylistRows"]: ) -> Sequence["PlaylistRows"]:
""" """
For passed playlist, return a list of rows that For passed playlist, return a list of rows that
have been played. have been played.
@ -529,7 +548,7 @@ class PlaylistRows(Base):
playlist_id: int, playlist_id: int,
from_row: Optional[int] = None, from_row: Optional[int] = None,
to_row: Optional[int] = None, to_row: Optional[int] = None,
) -> List["PlaylistRows"]: ) -> Sequence["PlaylistRows"]:
""" """
For passed playlist, return a list of rows that For passed playlist, return a list of rows that
contain tracks contain tracks
@ -550,7 +569,7 @@ class PlaylistRows(Base):
@classmethod @classmethod
def get_unplayed_rows( def get_unplayed_rows(
cls, session: scoped_session, playlist_id: int cls, session: scoped_session, playlist_id: int
) -> List["PlaylistRows"]: ) -> Sequence["PlaylistRows"]:
""" """
For passed playlist, return a list of playlist rows that For passed playlist, return a list of playlist rows that
have not been played. have not been played.
@ -596,11 +615,11 @@ class Settings(Base):
__tablename__ = "settings" __tablename__ = "settings"
id: int = Column(Integer, primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
name: str = Column(String(64), nullable=False, unique=True) name: Mapped[str] = mapped_column(String(64), unique=True)
f_datetime = Column(DateTime, default=None, nullable=True) f_datetime: Mapped[Optional[datetime]] = mapped_column(default=None)
f_int: int = Column(Integer, default=None, nullable=True) f_int: Mapped[Optional[int]] = mapped_column(default=None)
f_string = Column(String(128), default=None, nullable=True) f_string: Mapped[Optional[str]] = mapped_column(String(128), default=None)
def __repr__(self) -> str: def __repr__(self) -> str:
value = self.f_datetime or self.f_int or self.f_string value = self.f_datetime or self.f_int or self.f_string
@ -611,6 +630,20 @@ class Settings(Base):
session.add(self) session.add(self)
session.flush() session.flush()
@classmethod
def all_as_dict(cls, session):
"""
Return all setting in a dictionary keyed by name
"""
result = {}
settings = session.execute(select(cls)).scalars().all()
for setting in settings:
result[setting.name] = setting
return result
@classmethod @classmethod
def get_int_settings(cls, session: scoped_session, name: str) -> "Settings": def get_int_settings(cls, session: scoped_session, name: str) -> "Settings":
"""Get setting for an integer or return new setting record""" """Get setting for an integer or return new setting record"""
@ -631,21 +664,25 @@ class Settings(Base):
class Tracks(Base): class Tracks(Base):
__tablename__ = "tracks" __tablename__ = "tracks"
id: int = Column(Integer, primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
title = Column(String(256), index=True) title: Mapped[str] = mapped_column(String(256), index=True)
artist = Column(String(256), index=True) artist: Mapped[str] = mapped_column(String(256), index=True)
duration = Column(Integer, index=True) duration: Mapped[int] = mapped_column(index=True)
start_gap = Column(Integer, index=False) start_gap: Mapped[int] = mapped_column(index=False)
fade_at = Column(Integer, index=False) fade_at: Mapped[int] = mapped_column(index=False)
silence_at = Column(Integer, index=False) silence_at: Mapped[int] = mapped_column(index=False)
path: str = Column(String(2048), index=False, nullable=False, unique=True) path: Mapped[str] = mapped_column(String(2048), index=False, unique=True)
mtime = Column(Float, index=True) mtime: Mapped[float] = mapped_column(index=True)
bitrate = Column(Integer, nullable=True, default=None) bitrate: Mapped[Optional[int]] = mapped_column(default=None)
playlistrows: List[PlaylistRows] = relationship( playlistrows: Mapped[List[PlaylistRows]] = relationship(
"PlaylistRows", back_populates="track" "PlaylistRows", back_populates="track"
) )
playlists = association_proxy("playlistrows", "playlist") playlists = association_proxy("playlistrows", "playlist")
playdates: List[Playdates] = relationship("Playdates", back_populates="track") playdates: Mapped[List[Playdates]] = relationship(
"Playdates",
back_populates="track",
lazy="joined",
)
def __repr__(self) -> str: def __repr__(self) -> str:
return ( return (
@ -707,7 +744,7 @@ class Tracks(Base):
return None return None
@classmethod @classmethod
def search_artists(cls, session: scoped_session, text: str) -> List["Tracks"]: def search_artists(cls, session: scoped_session, text: str) -> Sequence["Tracks"]:
""" """
Search case-insenstively for artists containing str Search case-insenstively for artists containing str
@ -729,7 +766,7 @@ class Tracks(Base):
) )
@classmethod @classmethod
def search_titles(cls, session: scoped_session, text: str) -> List["Tracks"]: def search_titles(cls, session: scoped_session, text: str) -> Sequence["Tracks"]:
""" """
Search case-insenstively for titles containing str Search case-insenstively for titles containing str

View File

@ -19,6 +19,7 @@ from typing import (
cast, cast,
List, List,
Optional, Optional,
Sequence,
) )
from PyQt6.QtCore import ( from PyQt6.QtCore import (
@ -33,6 +34,7 @@ from PyQt6.QtCore import (
QTimer, QTimer,
) )
from PyQt6.QtGui import ( from PyQt6.QtGui import (
QCloseEvent,
QColor, QColor,
QFont, QFont,
QMouseEvent, QMouseEvent,
@ -119,9 +121,12 @@ class CartButton(QPushButton):
f"path={self.path}, is_playing={self.is_playing}>" f"path={self.path}, is_playing={self.is_playing}>"
) )
def event(self, event: QEvent) -> bool: def event(self, event: Optional[QEvent]) -> bool:
"""Allow right click even when button is disabled""" """Allow right click even when button is disabled"""
if not event:
return False
if event.type() == QEvent.Type.MouseButtonRelease: if event.type() == QEvent.Type.MouseButtonRelease:
mouse_event = cast(QMouseEvent, event) mouse_event = cast(QMouseEvent, event)
if mouse_event.button() == Qt.MouseButton.RightButton: if mouse_event.button() == Qt.MouseButton.RightButton:
@ -130,7 +135,7 @@ class CartButton(QPushButton):
return super().event(event) return super().event(event)
def resizeEvent(self, event: QResizeEvent) -> None: def resizeEvent(self, event: Optional[QResizeEvent]) -> None:
"""Resize progess bar when button size changes""" """Resize progess bar when button size changes"""
self.pgb.setGeometry(0, 0, self.width(), 10) self.pgb.setGeometry(0, 0, self.width(), 10)
@ -534,9 +539,12 @@ class Window(QMainWindow, Ui_MainWindow):
# Clear the search bar # Clear the search bar
self.search_playlist_clear() self.search_playlist_clear()
def closeEvent(self, event: QEvent) -> None: def closeEvent(self, event: Optional[QCloseEvent]) -> None:
"""Handle attempt to close main window""" """Handle attempt to close main window"""
if not event:
return
# Don't allow window to close when a track is playing # Don't allow window to close when a track is playing
if self.playing: if self.playing:
event.ignore() event.ignore()
@ -545,19 +553,20 @@ class Window(QMainWindow, Ui_MainWindow):
) )
else: else:
with Session() as session: with Session() as session:
record = Settings.get_int_settings(session, "mainwindow_height") settings = Settings.all_as_dict(session)
record = settings["mainwindow_height"]
if record.f_int != self.height(): if record.f_int != self.height():
record.update(session, {"f_int": self.height()}) record.update(session, {"f_int": self.height()})
record = Settings.get_int_settings(session, "mainwindow_width") record = settings["mainwindow_width"]
if record.f_int != self.width(): if record.f_int != self.width():
record.update(session, {"f_int": self.width()}) record.update(session, {"f_int": self.width()})
record = Settings.get_int_settings(session, "mainwindow_x") record = settings["mainwindow_x"]
if record.f_int != self.x(): if record.f_int != self.x():
record.update(session, {"f_int": self.x()}) record.update(session, {"f_int": self.x()})
record = Settings.get_int_settings(session, "mainwindow_y") record = settings["mainwindow_y"]
if record.f_int != self.y(): if record.f_int != self.y():
record.update(session, {"f_int": self.y()}) record.update(session, {"f_int": self.y()})
@ -566,16 +575,16 @@ class Window(QMainWindow, Ui_MainWindow):
assert len(splitter_sizes) == 2 assert len(splitter_sizes) == 2
splitter_top, splitter_bottom = splitter_sizes splitter_top, splitter_bottom = splitter_sizes
record = Settings.get_int_settings(session, "splitter_top") record = settings["splitter_top"]
if record.f_int != splitter_top: if record.f_int != splitter_top:
record.update(session, {"f_int": splitter_top}) record.update(session, {"f_int": splitter_top})
record = Settings.get_int_settings(session, "splitter_bottom") record = settings["splitter_bottom"]
if record.f_int != splitter_bottom: if record.f_int != splitter_bottom:
record.update(session, {"f_int": splitter_bottom}) record.update(session, {"f_int": splitter_bottom})
# Save current tab # Save current tab
record = Settings.get_int_settings(session, "active_tab") record = settings["active_tab"]
record.update(session, {"f_int": self.tabPlaylist.currentIndex()}) record.update(session, {"f_int": self.tabPlaylist.currentIndex()})
event.accept() event.accept()
@ -1037,11 +1046,11 @@ class Window(QMainWindow, Ui_MainWindow):
_ = self.create_playlist_tab(session, playlist) _ = self.create_playlist_tab(session, playlist)
# Set active tab # Set active tab
record = Settings.get_int_settings(session, "active_tab") record = Settings.get_int_settings(session, "active_tab")
if record and record.f_int >= 0: if record.f_int and record.f_int >= 0:
self.tabPlaylist.setCurrentIndex(record.f_int) self.tabPlaylist.setCurrentIndex(record.f_int)
def move_playlist_rows( def move_playlist_rows(
self, session: scoped_session, playlistrows: List[PlaylistRows] self, session: scoped_session, playlistrows: Sequence[PlaylistRows]
) -> None: ) -> None:
""" """
Move passed playlist rows to another playlist Move passed playlist rows to another playlist
@ -1104,7 +1113,7 @@ class Window(QMainWindow, Ui_MainWindow):
visible_tab.save_playlist(session) visible_tab.save_playlist(session)
# Disable sort undo # Disable sort undo
self.sort_undo = None self.sort_undo = []
# Update destination playlist_tab if visible (if not visible, it # Update destination playlist_tab if visible (if not visible, it
# will be re-populated when it is opened) # will be re-populated when it is opened)
@ -1484,18 +1493,19 @@ class Window(QMainWindow, Ui_MainWindow):
"""Set size of window from database""" """Set size of window from database"""
with Session() as session: with Session() as session:
record = Settings.get_int_settings(session, "mainwindow_x") settings = Settings.all_as_dict(session)
record = settings["mainwindow_x"]
x = record.f_int or 1 x = record.f_int or 1
record = Settings.get_int_settings(session, "mainwindow_y") record = settings["mainwindow_y"]
y = record.f_int or 1 y = record.f_int or 1
record = Settings.get_int_settings(session, "mainwindow_width") record = settings["mainwindow_width"]
width = record.f_int or 1599 width = record.f_int or 1599
record = Settings.get_int_settings(session, "mainwindow_height") record = settings["mainwindow_height"]
height = record.f_int or 981 height = record.f_int or 981
self.setGeometry(x, y, width, height) self.setGeometry(x, y, width, height)
record = Settings.get_int_settings(session, "splitter_top") record = settings["splitter_top"]
splitter_top = record.f_int or 256 splitter_top = record.f_int or 256
record = Settings.get_int_settings(session, "splitter_bottom") record = settings["splitter_bottom"]
splitter_bottom = record.f_int or 256 splitter_bottom = record.f_int or 256
self.splitter.setSizes([splitter_top, splitter_bottom]) self.splitter.setSizes([splitter_top, splitter_bottom])
return return
@ -2161,6 +2171,6 @@ if __name__ == "__main__":
msg, msg,
) )
print("\033[1;31;47mUnhandled exception starts") # print("\033[1;31;47mUnhandled exception starts")
stackprinter.show(style="darkbg") # stackprinter.show(style="darkbg")
print("Unhandled exception ends\033[1;37;40m") # print("Unhandled exception ends\033[1;37;40m")

View File

@ -164,16 +164,19 @@ class PlaylistTab(QTableWidget):
self.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel) self.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
self.setRowCount(0) self.setRowCount(0)
self.setColumnCount(len(columns)) self.setColumnCount(len(columns))
self.v_header = self.verticalHeader()
self.v_header.setMinimumSectionSize(Config.MINIMUM_ROW_HEIGHT)
self.horizontalHeader().setStretchLastSection(True)
# Header row # Header row
self.h_header = self.horizontalHeader()
for idx in [a for a in range(len(columns))]: for idx in [a for a in range(len(columns))]:
item = QTableWidgetItem() item = QTableWidgetItem()
self.setHorizontalHeaderItem(idx, item) self.setHorizontalHeaderItem(idx, item)
self.horizontalHeader().setMinimumSectionSize(0) if self.h_header:
self.h_header.setStretchLastSection(True)
self.h_header.setMinimumSectionSize(0)
# Set column headings sorted by idx # Set column headings sorted by idx
self.v_header = self.verticalHeader()
if self.v_header:
self.v_header.setMinimumSectionSize(Config.MINIMUM_ROW_HEIGHT)
self.setHorizontalHeaderLabels( self.setHorizontalHeaderLabels(
[ [
a.heading a.heading
@ -215,13 +218,16 @@ class PlaylistTab(QTableWidget):
# ########## Events other than cell editing ########## # ########## Events other than cell editing ##########
def dropEvent(self, event: QDropEvent) -> None: def dropEvent(self, event: Optional[QDropEvent]) -> None:
""" """
Handle drag/drop of rows Handle drag/drop of rows
https://stackoverflow.com/questions/26227885/drag-and-drop-rows-within-qtablewidget https://stackoverflow.com/questions/26227885/drag-and-drop-rows-within-qtablewidget
""" """
if not event:
return
if not event.source() == self: if not event.source() == self:
return # We don't accept external drops return # We don't accept external drops
@ -267,7 +273,7 @@ class PlaylistTab(QTableWidget):
# Reset drag mode to allow row selection by dragging # Reset drag mode to allow row selection by dragging
self.setDragEnabled(False) self.setDragEnabled(False)
# Disable sort undo # Disable sort undo
self.sort_undo = None self.sort_undo = []
with Session() as session: with Session() as session:
self.save_playlist(session) self.save_playlist(session)
@ -613,8 +619,6 @@ class PlaylistTab(QTableWidget):
if played: if played:
bold = False bold = False
_ = self._set_row_userdata(row_number, self.PLAYED, True) _ = self._set_row_userdata(row_number, self.PLAYED, True)
if plr.note is None:
plr.note = ""
self._set_row_note_text(session, row_number, plr.note) self._set_row_note_text(session, row_number, plr.note)
else: else:
# This is a section header so it must have note text # This is a section header so it must have note text
@ -745,7 +749,7 @@ class PlaylistTab(QTableWidget):
stackprinter.format(), stackprinter.format(),
) )
print("playlists:play_started:current_row is None") print("playlists:play_started:current_row is None")
stackprinter.show(add_summary=True, style="darkbg") # stackprinter.show(add_summary=True, style="darkbg")
return return
# Mark current row as played # Mark current row as played
@ -797,10 +801,10 @@ class PlaylistTab(QTableWidget):
stackprinter.format(), stackprinter.format(),
) )
print("playlists:populate_display:no playlist") print("playlists:populate_display:no playlist")
stackprinter.show(add_summary=True, style="darkbg") # stackprinter.show(add_summary=True, style="darkbg")
return return
for plr in playlist.rows: for plr in PlaylistRows.deep_rows(session, playlist_id):
self.insert_row( self.insert_row(
session, session,
plr, plr,
@ -817,7 +821,6 @@ class PlaylistTab(QTableWidget):
# Set widths # Set widths
self._set_column_widths(session) self._set_column_widths(session)
self.save_playlist(session)
# Queue up time calculations to take place after UI has # Queue up time calculations to take place after UI has
# updated # updated
self._update_start_end_times(session) self._update_start_end_times(session)
@ -1106,7 +1109,7 @@ class PlaylistTab(QTableWidget):
) )
if sort_menu: if sort_menu:
sort_menu.setEnabled(self._sortable()) sort_menu.setEnabled(self._sortable())
self._add_context_menu("Undo sort", self._sort_undo, self.sort_undo is None) self._add_context_menu("Undo sort", self._sort_undo, not bool(self.sort_undo))
# Build submenu # Build submenu
@ -1143,6 +1146,7 @@ class PlaylistTab(QTableWidget):
""" """
with Session() as session: with Session() as session:
settings = Settings.all_as_dict(session)
for column_name, data in columns.items(): for column_name, data in columns.items():
idx = data.idx idx = data.idx
if idx == len(columns) - 1: if idx == len(columns) - 1:
@ -1151,7 +1155,7 @@ class PlaylistTab(QTableWidget):
continue continue
width = self.columnWidth(idx) width = self.columnWidth(idx)
attribute_name = f"playlist_{column_name}_col_width" attribute_name = f"playlist_{column_name}_col_width"
record = Settings.get_int_settings(session, attribute_name) record = settings[attribute_name]
if record.f_int != self.columnWidth(idx): if record.f_int != self.columnWidth(idx):
record.update(session, {"f_int": width}) record.update(session, {"f_int": width})
@ -1896,6 +1900,8 @@ class PlaylistTab(QTableWidget):
def _set_column_widths(self, session: scoped_session) -> None: def _set_column_widths(self, session: scoped_session) -> None:
"""Column widths from settings""" """Column widths from settings"""
settings = Settings.all_as_dict(session)
for column_name, data in columns.items(): for column_name, data in columns.items():
idx = data.idx idx = data.idx
if idx == len(columns) - 1: if idx == len(columns) - 1:
@ -1903,7 +1909,7 @@ class PlaylistTab(QTableWidget):
self.setColumnWidth(idx, 0) self.setColumnWidth(idx, 0)
continue continue
attr_name = f"playlist_{column_name}_col_width" attr_name = f"playlist_{column_name}_col_width"
record: Settings = Settings.get_int_settings(session, attr_name) record = settings[attr_name]
if record and record.f_int >= 0: if record and record.f_int >= 0:
self.setColumnWidth(idx, record.f_int) self.setColumnWidth(idx, record.f_int)
else: else:
@ -2080,8 +2086,10 @@ class PlaylistTab(QTableWidget):
"playlists:_set_row_header_text() called on track row", "playlists:_set_row_header_text() called on track row",
stackprinter.format(), stackprinter.format(),
) )
print("playists:_set_row_header_text() called on track row") print(
stackprinter.show(add_summary=True, style="darkbg") f"playists:_set_row_header_text() called on track row ({row_number=}, {text=}"
)
# stackprinter.show(add_summary=True, style="darkbg")
return return
# Set text # Set text
@ -2120,8 +2128,8 @@ class PlaylistTab(QTableWidget):
"playlists:_set_row_note_colour() on header row", "playlists:_set_row_note_colour() on header row",
stackprinter.format(), stackprinter.format(),
) )
print("playists:_set_row_note_colour() called on header row") # stackprinter.show(add_summary=True, style="darkbg")
stackprinter.show(add_summary=True, style="darkbg") print(f"playists:_set_row_note_colour() called on track row ({row_number=}")
return return
# Set colour # Set colour
@ -2146,8 +2154,10 @@ class PlaylistTab(QTableWidget):
"playlists:_set_row_note_text() called on header row", "playlists:_set_row_note_text() called on header row",
stackprinter.format(), stackprinter.format(),
) )
print("playists:_set_row_note_text() called on header row") print(
stackprinter.show(add_summary=True, style="darkbg") f"playists:_set_row_note_text() called on header row ({row_number=}, {text=}"
)
# stackprinter.show(add_summary=True, style="darkbg")
return return
# Set text # Set text
@ -2388,9 +2398,11 @@ class PlaylistTab(QTableWidget):
_ = self._set_row_bitrate(row, track.bitrate) _ = self._set_row_bitrate(row, track.bitrate)
_ = self._set_row_duration(row, track.duration) _ = self._set_row_duration(row, track.duration)
_ = self._set_row_end_time(row, None) _ = self._set_row_end_time(row, None)
_ = self._set_row_last_played_time( if track.playdates:
row, Playdates.last_played(session, track.id) last_play = max([a.lastplayed for a in track.playdates])
) else:
last_play = Config.EPOCH
_ = self._set_row_last_played_time(row, last_play)
_ = self._set_row_start_gap(row, track.start_gap) _ = self._set_row_start_gap(row, track.start_gap)
_ = self._set_row_start_time(row, None) _ = self._set_row_start_time(row, None)
_ = self._set_row_title(row, track.title) _ = self._set_row_title(row, track.title)

View File

@ -1,14 +1,12 @@
# -*- coding: utf-8 -*- # Form implementation generated from reading ui file 'dlg_Cart.ui'
# Form implementation generated from reading ui file 'app/ui/dlg_Cart.ui'
# #
# Created by: PyQt5 UI code generator 5.15.6 # Created by: PyQt6 UI code generator 6.5.3
# #
# WARNING: Any manual changes made to this file will be lost when pyuic5 is # WARNING: Any manual changes made to this file will be lost when pyuic6 is
# run again. Do not edit this file unless you know what you are doing. # run again. Do not edit this file unless you know what you are doing.
from PyQt5 import QtCore, QtGui, QtWidgets from PyQt6 import QtCore, QtGui, QtWidgets
class Ui_DialogCartEdit(object): class Ui_DialogCartEdit(object):
@ -17,43 +15,43 @@ class Ui_DialogCartEdit(object):
DialogCartEdit.resize(564, 148) DialogCartEdit.resize(564, 148)
self.gridLayout = QtWidgets.QGridLayout(DialogCartEdit) self.gridLayout = QtWidgets.QGridLayout(DialogCartEdit)
self.gridLayout.setObjectName("gridLayout") self.gridLayout.setObjectName("gridLayout")
self.label = QtWidgets.QLabel(DialogCartEdit) self.label = QtWidgets.QLabel(parent=DialogCartEdit)
self.label.setMaximumSize(QtCore.QSize(56, 16777215)) self.label.setMaximumSize(QtCore.QSize(56, 16777215))
self.label.setObjectName("label") self.label.setObjectName("label")
self.gridLayout.addWidget(self.label, 0, 0, 1, 1) self.gridLayout.addWidget(self.label, 0, 0, 1, 1)
self.lineEditName = QtWidgets.QLineEdit(DialogCartEdit) self.lineEditName = QtWidgets.QLineEdit(parent=DialogCartEdit)
self.lineEditName.setInputMask("") self.lineEditName.setInputMask("")
self.lineEditName.setObjectName("lineEditName") self.lineEditName.setObjectName("lineEditName")
self.gridLayout.addWidget(self.lineEditName, 0, 1, 1, 2) self.gridLayout.addWidget(self.lineEditName, 0, 1, 1, 2)
self.chkEnabled = QtWidgets.QCheckBox(DialogCartEdit) self.chkEnabled = QtWidgets.QCheckBox(parent=DialogCartEdit)
self.chkEnabled.setObjectName("chkEnabled") self.chkEnabled.setObjectName("chkEnabled")
self.gridLayout.addWidget(self.chkEnabled, 0, 3, 1, 1) self.gridLayout.addWidget(self.chkEnabled, 0, 3, 1, 1)
self.label_2 = QtWidgets.QLabel(DialogCartEdit) self.label_2 = QtWidgets.QLabel(parent=DialogCartEdit)
self.label_2.setMaximumSize(QtCore.QSize(56, 16777215)) self.label_2.setMaximumSize(QtCore.QSize(56, 16777215))
self.label_2.setObjectName("label_2") self.label_2.setObjectName("label_2")
self.gridLayout.addWidget(self.label_2, 1, 0, 1, 1) self.gridLayout.addWidget(self.label_2, 1, 0, 1, 1)
self.lblPath = QtWidgets.QLabel(DialogCartEdit) self.lblPath = QtWidgets.QLabel(parent=DialogCartEdit)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Preferred) sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Preferred, QtWidgets.QSizePolicy.Policy.Preferred)
sizePolicy.setHorizontalStretch(0) sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0) sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.lblPath.sizePolicy().hasHeightForWidth()) sizePolicy.setHeightForWidth(self.lblPath.sizePolicy().hasHeightForWidth())
self.lblPath.setSizePolicy(sizePolicy) self.lblPath.setSizePolicy(sizePolicy)
self.lblPath.setMinimumSize(QtCore.QSize(301, 41)) self.lblPath.setMinimumSize(QtCore.QSize(301, 41))
self.lblPath.setText("") self.lblPath.setText("")
self.lblPath.setTextFormat(QtCore.Qt.PlainText) self.lblPath.setTextFormat(QtCore.Qt.TextFormat.PlainText)
self.lblPath.setAlignment(QtCore.Qt.AlignLeading|QtCore.Qt.AlignLeft|QtCore.Qt.AlignTop) self.lblPath.setAlignment(QtCore.Qt.AlignmentFlag.AlignLeading|QtCore.Qt.AlignmentFlag.AlignLeft|QtCore.Qt.AlignmentFlag.AlignTop)
self.lblPath.setWordWrap(True) self.lblPath.setWordWrap(True)
self.lblPath.setObjectName("lblPath") self.lblPath.setObjectName("lblPath")
self.gridLayout.addWidget(self.lblPath, 1, 1, 1, 1) self.gridLayout.addWidget(self.lblPath, 1, 1, 1, 1)
self.btnFile = QtWidgets.QPushButton(DialogCartEdit) self.btnFile = QtWidgets.QPushButton(parent=DialogCartEdit)
self.btnFile.setMaximumSize(QtCore.QSize(31, 16777215)) self.btnFile.setMaximumSize(QtCore.QSize(31, 16777215))
self.btnFile.setObjectName("btnFile") self.btnFile.setObjectName("btnFile")
self.gridLayout.addWidget(self.btnFile, 1, 3, 1, 1) self.gridLayout.addWidget(self.btnFile, 1, 3, 1, 1)
spacerItem = QtWidgets.QSpacerItem(116, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) spacerItem = QtWidgets.QSpacerItem(116, 20, QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum)
self.gridLayout.addItem(spacerItem, 2, 1, 1, 1) self.gridLayout.addItem(spacerItem, 2, 1, 1, 1)
self.buttonBox = QtWidgets.QDialogButtonBox(DialogCartEdit) self.buttonBox = QtWidgets.QDialogButtonBox(parent=DialogCartEdit)
self.buttonBox.setOrientation(QtCore.Qt.Horizontal) self.buttonBox.setOrientation(QtCore.Qt.Orientation.Horizontal)
self.buttonBox.setStandardButtons(QtWidgets.QDialogButtonBox.Cancel|QtWidgets.QDialogButtonBox.Ok) self.buttonBox.setStandardButtons(QtWidgets.QDialogButtonBox.StandardButton.Cancel|QtWidgets.QDialogButtonBox.StandardButton.Ok)
self.buttonBox.setObjectName("buttonBox") self.buttonBox.setObjectName("buttonBox")
self.gridLayout.addWidget(self.buttonBox, 2, 2, 1, 2) self.gridLayout.addWidget(self.buttonBox, 2, 2, 1, 2)
self.label.setBuddy(self.lineEditName) self.label.setBuddy(self.lineEditName)

View File

@ -1,10 +1,34 @@
# -*- coding: utf-8 -*- # Form implementation generated from reading ui file 'dlg_SelectPlaylist.ui'
# Form implementation generated from reading ui file 'ui/playlist.ui'
# #
# Created by: PyQt5 UI code generator 5.15.4 # Created by: PyQt6 UI code generator 6.5.3
# #
# WARNING: Any manual changes made to this file will be lost when pyuic5 is # WARNING: Any manual changes made to this file will be lost when pyuic6 is
# run again. Do not edit this file unless you know what you are doing. # run again. Do not edit this file unless you know what you are doing.
from PyQt6 import QtCore, QtGui, QtWidgets
class Ui_dlgSelectPlaylist(object):
def setupUi(self, dlgSelectPlaylist):
dlgSelectPlaylist.setObjectName("dlgSelectPlaylist")
dlgSelectPlaylist.resize(276, 150)
self.verticalLayout = QtWidgets.QVBoxLayout(dlgSelectPlaylist)
self.verticalLayout.setObjectName("verticalLayout")
self.lstPlaylists = QtWidgets.QListWidget(parent=dlgSelectPlaylist)
self.lstPlaylists.setObjectName("lstPlaylists")
self.verticalLayout.addWidget(self.lstPlaylists)
self.buttonBox = QtWidgets.QDialogButtonBox(parent=dlgSelectPlaylist)
self.buttonBox.setOrientation(QtCore.Qt.Orientation.Horizontal)
self.buttonBox.setStandardButtons(QtWidgets.QDialogButtonBox.StandardButton.Cancel|QtWidgets.QDialogButtonBox.StandardButton.Ok)
self.buttonBox.setObjectName("buttonBox")
self.verticalLayout.addWidget(self.buttonBox)
self.retranslateUi(dlgSelectPlaylist)
self.buttonBox.accepted.connect(dlgSelectPlaylist.accept) # type: ignore
self.buttonBox.rejected.connect(dlgSelectPlaylist.reject) # type: ignore
QtCore.QMetaObject.connectSlotsByName(dlgSelectPlaylist)
def retranslateUi(self, dlgSelectPlaylist):
_translate = QtCore.QCoreApplication.translate
dlgSelectPlaylist.setWindowTitle(_translate("dlgSelectPlaylist", "Dialog"))

View File

@ -0,0 +1,72 @@
"""Migrate SQLA 2 and remove redundant columns
Revision ID: 3a53a9fb26ab
Revises: 07dcbe6c4f0e
Create Date: 2023-10-15 09:39:16.449419
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = '3a53a9fb26ab'
down_revision = '07dcbe6c4f0e'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('playlists', 'query')
op.drop_column('playlists', 'sort_column')
op.alter_column('tracks', 'title',
existing_type=mysql.VARCHAR(length=256),
nullable=False)
op.alter_column('tracks', 'artist',
existing_type=mysql.VARCHAR(length=256),
nullable=False)
op.alter_column('tracks', 'duration',
existing_type=mysql.INTEGER(display_width=11),
nullable=False)
op.alter_column('tracks', 'start_gap',
existing_type=mysql.INTEGER(display_width=11),
nullable=False)
op.alter_column('tracks', 'fade_at',
existing_type=mysql.INTEGER(display_width=11),
nullable=False)
op.alter_column('tracks', 'silence_at',
existing_type=mysql.INTEGER(display_width=11),
nullable=False)
op.alter_column('tracks', 'mtime',
existing_type=mysql.FLOAT(),
nullable=False)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('tracks', 'mtime',
existing_type=mysql.FLOAT(),
nullable=True)
op.alter_column('tracks', 'silence_at',
existing_type=mysql.INTEGER(display_width=11),
nullable=True)
op.alter_column('tracks', 'fade_at',
existing_type=mysql.INTEGER(display_width=11),
nullable=True)
op.alter_column('tracks', 'start_gap',
existing_type=mysql.INTEGER(display_width=11),
nullable=True)
op.alter_column('tracks', 'duration',
existing_type=mysql.INTEGER(display_width=11),
nullable=True)
op.alter_column('tracks', 'artist',
existing_type=mysql.VARCHAR(length=256),
nullable=True)
op.alter_column('tracks', 'title',
existing_type=mysql.VARCHAR(length=256),
nullable=True)
op.add_column('playlists', sa.Column('sort_column', mysql.INTEGER(display_width=11), autoincrement=False, nullable=True))
op.add_column('playlists', sa.Column('query', mysql.VARCHAR(length=256), nullable=True))
# ### end Alembic commands ###

1293
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -7,13 +7,12 @@ authors = ["Keith Edmunds <kae@midnighthax.com>"]
[tool.poetry.dependencies] [tool.poetry.dependencies]
python = "^3.9" python = "^3.9"
tinytag = "^1.7.0" tinytag = "^1.7.0"
SQLAlchemy = "^1.4.31" SQLAlchemy = "^2.0.22"
python-vlc = "^3.0.12118" python-vlc = "^3.0.12118"
mysqlclient = "^2.1.0" mysqlclient = "^2.1.0"
mutagen = "^1.45.1" mutagen = "^1.45.1"
alembic = "^1.7.5" alembic = "^1.7.5"
psutil = "^5.9.0" psutil = "^5.9.0"
PyQtWebEngine = "^5.15.5"
pydub = "^0.25.1" pydub = "^0.25.1"
types-psutil = "^5.8.22" types-psutil = "^5.8.22"
python-slugify = "^6.1.2" python-slugify = "^6.1.2"
@ -35,8 +34,6 @@ pytest-qt = "^4.0.2"
pydub-stubs = "^0.25.1" pydub-stubs = "^0.25.1"
line-profiler = "^4.0.2" line-profiler = "^4.0.2"
flakehell = "^0.9.0" flakehell = "^0.9.0"
sqlalchemy2-stubs = "^0.0.2-alpha.32"
mypy = "^0.991"
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]
pudb = "^2022.1.3" pudb = "^2022.1.3"
@ -44,6 +41,7 @@ sphinx = "^7.0.1"
furo = "^2023.5.20" furo = "^2023.5.20"
black = "^23.3.0" black = "^23.3.0"
flakehell = "^0.9.0" flakehell = "^0.9.0"
mypy = "^1.6.0"
[build-system] [build-system]
requires = ["poetry-core>=1.0.0"] requires = ["poetry-core>=1.0.0"]
@ -52,7 +50,6 @@ build-backend = "poetry.core.masonry.api"
[tool.mypy] [tool.mypy]
# mypy_path = "/home/kae/.cache/pypoetry/virtualenvs/musicmuster-oWgGw1IG-py3.9:/home/kae/git/musicmuster/app" # mypy_path = "/home/kae/.cache/pypoetry/virtualenvs/musicmuster-oWgGw1IG-py3.9:/home/kae/git/musicmuster/app"
mypy_path = "/home/kae/git/musicmuster/app" mypy_path = "/home/kae/git/musicmuster/app"
plugins = "sqlalchemy.ext.mypy.plugin"
[tool.vulture] [tool.vulture]
exclude = ["migrations", "app/ui", "archive"] exclude = ["migrations", "app/ui", "archive"]