Compare commits

...

16 Commits

Author SHA1 Message Date
Keith Edmunds
2abb672142 Cascade deletes for tracks→playdates 2025-02-23 21:06:42 +00:00
Keith Edmunds
3f248d363f rebase from dev 2025-02-23 21:06:42 +00:00
Keith Edmunds
40756469ec WIP query tabs 2025-02-23 21:06:42 +00:00
Keith Edmunds
306ab103b6 Add favourite to queries table 2025-02-23 21:06:42 +00:00
Keith Edmunds
994d510ed9 Move querylistmodel from SQL to filter 2025-02-23 21:06:42 +00:00
Keith Edmunds
8b8edba64d Add Filter class to classes 2025-02-23 21:06:42 +00:00
Keith Edmunds
678515403c Guard against erroneous SQL statements in queries 2025-02-23 21:06:42 +00:00
Keith Edmunds
e6404d075e Query searches working
More UI needed
2025-02-23 21:06:42 +00:00
Keith Edmunds
7c0db00b75 Create databases in dbmanager 2025-02-23 21:06:42 +00:00
Keith Edmunds
e4e061cf1c Add open querylist menu 2025-02-23 21:06:42 +00:00
Keith Edmunds
61021b33b8 Fix hide played button 2025-02-23 21:06:42 +00:00
Keith Edmunds
a33589a9a1 "=" header fixes
Fixes: #276
2025-02-23 21:06:42 +00:00
Keith Edmunds
3547046cc1 Misc cleanups from query_tabs branch 2025-02-23 21:06:41 +00:00
Keith Edmunds
95983c73b1 Log to stderr timer10 stop/start 2025-02-23 21:06:41 +00:00
Keith Edmunds
499c0c6b70 Fix "=" header
Fixes: #276
2025-02-23 21:06:41 +00:00
Keith Edmunds
33e2c4bf31 Fix order of playdates on hover
Fixes: #275
2025-02-23 21:06:41 +00:00
24 changed files with 2427 additions and 914 deletions

View File

@ -5,7 +5,7 @@ from dataclasses import dataclass
from enum import auto, Enum
import functools
import threading
from typing import NamedTuple
from typing import NamedTuple, Optional
# Third party imports
@ -71,6 +71,17 @@ class FileErrors(NamedTuple):
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):
"""
Custom exception

View File

@ -87,6 +87,7 @@ class Config(object):
MAX_MISSING_FILES_TO_REPORT = 10
MILLISECOND_SIGFIGS = 0
MINIMUM_ROW_HEIGHT = 30
NO_QUERY_NAME = "Select query"
NO_TEMPLATE_NAME = "None"
NOTE_TIME_FORMAT = "%H:%M"
OBS_HOST = "localhost"
@ -96,6 +97,7 @@ class Config(object):
PLAY_SETTLE = 500000
PLAYLIST_ICON_CURRENT = ":/icons/green-circle.png"
PLAYLIST_ICON_NEXT = ":/icons/yellow-circle.png"
PLAYLIST_ICON_TEMPLATE = ":/icons/redstar.png"
PREVIEW_ADVANCE_MS = 5000
PREVIEW_BACK_MS = 5000
PREVIEW_END_BUFFER_MS = 1000

View File

@ -18,6 +18,7 @@ class DatabaseManager:
def __init__(self, database_url: str, **kwargs: dict) -> None:
if DatabaseManager.__instance is None:
self.db = Alchemical(database_url, **kwargs)
self.db.create_all()
DatabaseManager.__instance = self
else:
raise Exception("Attempted to create a second DatabaseManager instance")

View File

@ -48,7 +48,7 @@ class PlaydatesTable(Model):
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=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(
"TracksTable",
back_populates="playdates",
@ -80,8 +80,8 @@ class PlaylistsTable(Model):
cascade="all, delete-orphan",
order_by="PlaylistRowsTable.row_number",
)
query: Mapped["QueriesTable"] = relationship(
back_populates="playlist", cascade="all, delete-orphan"
favourite: Mapped[bool] = mapped_column(
Boolean, nullable=False, index=False, default=False
)
def __repr__(self) -> str:
@ -125,16 +125,16 @@ class QueriesTable(Model):
__tablename__ = "queries"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
query: Mapped[str] = mapped_column(
name: Mapped[str] = mapped_column(String(128), nullable=False)
sql: Mapped[str] = mapped_column(
String(2048), index=False, default="", nullable=False
)
playlist_id: Mapped[int] = mapped_column(
ForeignKey("playlists.id", ondelete="CASCADE"), index=True
favourite: Mapped[bool] = mapped_column(
Boolean, nullable=False, index=False, default=False
)
playlist: Mapped[PlaylistsTable] = relationship(back_populates="query")
def __repr__(self) -> str:
return f"<Queries(id={self.id}, playlist={self.playlist}, query={self.query}>"
return f"<Queries(id={self.id}, name={self.name}, sql={self.sql}>"
class SettingsTable(Model):

View File

@ -22,6 +22,9 @@ filters:
# module-name:
# - function-name-1
# - function-name-2
musicmuster:
- update_clocks
- play_next
handlers:
stderr:

102
app/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

@ -15,14 +15,17 @@ from sqlalchemy import (
delete,
func,
select,
text,
update,
)
from sqlalchemy.exc import IntegrityError
from sqlalchemy.exc import IntegrityError, ProgrammingError
from sqlalchemy.orm.exc import NoResultFound
from sqlalchemy.orm import joinedload, selectinload
from sqlalchemy.orm import joinedload
from sqlalchemy.orm.session import Session
from sqlalchemy.engine.row import RowMapping
# App imports
from classes import ApplicationError
from config import Config
from dbmanager import DatabaseManager
import dbtables
@ -38,6 +41,17 @@ if "unittest" in sys.modules and "sqlite" not in DATABASE_URL:
db = DatabaseManager.get_instance(DATABASE_URL, engine_options=Config.ENGINE_OPTIONS).db
def run_sql(session: Session, sql: str) -> Sequence[RowMapping]:
"""
Run a sql string and return results
"""
try:
return session.execute(text(sql)).mappings().all()
except ProgrammingError as e:
raise ApplicationError(e)
# Database classes
class NoteColours(dbtables.NoteColoursTable):
def __init__(
@ -128,13 +142,13 @@ class Playdates(dbtables.PlaydatesTable):
) -> Sequence["Playdates"]:
"""
Return a list of the last limit playdates for this track, sorted
earliest to latest.
latest to earliest.
"""
return session.scalars(
Playdates.select()
.where(Playdates.track_id == track_id)
.order_by(Playdates.lastplayed.asc())
.order_by(Playdates.lastplayed.desc())
.limit(limit)
).all()
@ -179,12 +193,18 @@ class Playdates(dbtables.PlaydatesTable):
class Playlists(dbtables.PlaylistsTable):
def __init__(self, session: Session, name: str):
def __init__(self, session: Session, name: str, template_id: int) -> None:
"""Create playlist with passed name"""
self.name = name
self.last_used = dt.datetime.now()
session.add(self)
session.commit()
# If a template is specified, copy from it
if template_id:
PlaylistRows.copy_playlist(session, template_id, self.id)
@staticmethod
def clear_tabs(session: Session, playlist_ids: list[int]) -> None:
"""
@ -201,26 +221,6 @@ class Playlists(dbtables.PlaylistsTable):
self.open = False
session.commit()
@classmethod
def create_playlist_from_template(
cls, session: Session, template: "Playlists", playlist_name: str
) -> Optional["Playlists"]:
"""Create a new playlist from template"""
# Sanity check
if not template.id:
return None
playlist = cls(session, playlist_name)
# Sanity / mypy checks
if not playlist or not playlist.id:
return None
PlaylistRows.copy_playlist(session, template.id, playlist.id)
return playlist
def delete(self, session: Session) -> None:
"""
Delete playlist
@ -235,23 +235,10 @@ class Playlists(dbtables.PlaylistsTable):
return session.scalars(
select(cls)
.filter(
cls.is_template.is_(False),
~cls.query.has()
)
.filter(cls.is_template.is_(False))
.order_by(cls.last_used.desc())
).all()
@classmethod
def get_all_queries(cls, session: Session) -> Sequence["Playlists"]:
"""Returns a list of all query lists ordered by name"""
return session.scalars(
select(cls)
.where(cls.query.has())
.order_by(cls.name)
).all()
@classmethod
def get_all_templates(cls, session: Session) -> Sequence["Playlists"]:
"""Returns a list of all templates ordered by name"""
@ -260,6 +247,19 @@ class Playlists(dbtables.PlaylistsTable):
select(cls).where(cls.is_template.is_(True)).order_by(cls.name)
).all()
@classmethod
def get_favourite_templates(cls, session: Session) -> Sequence["Playlists"]:
"""Returns a list of favourite templates ordered by name"""
return session.scalars(
select(cls)
.where(
cls.is_template.is_(True),
cls.favourite.is_(True)
)
.order_by(cls.name)
).all()
@classmethod
def get_closed(cls, session: Session) -> Sequence["Playlists"]:
"""Returns a list of all closed playlists ordered by last use"""
@ -269,7 +269,6 @@ class Playlists(dbtables.PlaylistsTable):
.filter(
cls.open.is_(False),
cls.is_template.is_(False),
~cls.query.has()
)
.order_by(cls.last_used.desc())
).all()
@ -281,13 +280,7 @@ class Playlists(dbtables.PlaylistsTable):
"""
return session.scalars(
select(cls)
.where(
cls.open.is_(True),
~cls.query.has()
)
.order_by(cls.tab)
select(cls).where(cls.open.is_(True)).order_by(cls.tab)
).all()
def mark_open(self) -> None:
@ -321,7 +314,7 @@ class Playlists(dbtables.PlaylistsTable):
) -> None:
"""Save passed playlist as new template"""
template = Playlists(session, template_name)
template = Playlists(session, template_name, template_id=0)
if not template or not template.id:
return
@ -331,26 +324,6 @@ class Playlists(dbtables.PlaylistsTable):
PlaylistRows.copy_playlist(session, playlist_id, template.id)
class Queries(dbtables.QueriesTable):
def __init__(self, session: Session, playlist_id: int, query: str = "") -> None:
self.playlist_id = playlist_id
self.query = query
session.add(self)
session.commit()
@staticmethod
def get_query(session: Session, playlist_id: int) -> str:
"""
Return query associated with playlist or null string if none
"""
return session.execute(
select(Queries.query).where(
Queries.playlist_id == playlist_id
)
).scalar_one()
class PlaylistRows(dbtables.PlaylistRowsTable):
def __init__(
self,
@ -636,7 +609,7 @@ class PlaylistRows(dbtables.PlaylistRowsTable):
class Settings(dbtables.SettingsTable):
def __init__(self, session: Session, name: str):
def __init__(self, session: Session, name: str) -> None:
self.name = name
session.add(self)
session.commit()
@ -664,7 +637,7 @@ class Tracks(dbtables.TracksTable):
fade_at: int,
silence_at: int,
bitrate: int,
):
) -> None:
self.path = path
self.title = title
self.artist = artist

File diff suppressed because it is too large Load Diff

View File

@ -26,12 +26,14 @@ from PyQt6.QtGui import (
)
# Third party imports
from sqlalchemy.orm.session import Session
import obswebsocket # type: ignore
# import snoop # type: ignore
# App imports
from classes import (
ApplicationError,
Col,
MusicMusterSignals,
)
@ -73,12 +75,14 @@ class PlaylistModel(QAbstractTableModel):
def __init__(
self,
playlist_id: int,
is_template: bool,
*args: Optional[QObject],
**kwargs: Optional[QObject],
) -> None:
log.debug("PlaylistModel.__init__()")
self.playlist_id = playlist_id
self.is_template = is_template
super().__init__(*args, **kwargs)
self.playlist_rows: dict[int, RowAndTrack] = {}
@ -497,7 +501,7 @@ class PlaylistModel(QAbstractTableModel):
"""
if not index.isValid():
return Qt.ItemFlag.NoItemFlags
return Qt.ItemFlag.ItemIsDropEnabled
default = (
Qt.ItemFlag.ItemIsEnabled
@ -771,7 +775,7 @@ class PlaylistModel(QAbstractTableModel):
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.
Distinguishes profile time between initial load and other
@ -1060,7 +1064,7 @@ class PlaylistModel(QAbstractTableModel):
# Update display
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
@ -1278,13 +1282,17 @@ class PlaylistModel(QAbstractTableModel):
unplayed_count: int = 0
duration: int = 0
if rat.row_number == 0:
# Meaningless to have a subtotal on row 0
return Config.SUBTOTAL_ON_ROW_ZERO
# Show subtotal
for row_number in range(rat.row_number - 1, -1, -1):
row_rat = self.playlist_rows[row_number]
if self.is_header_row(row_number):
if row_rat.note.endswith(Config.SECTION_STARTS):
if self.is_header_row(row_number) or row_number == 0:
if row_rat.note.endswith(Config.SECTION_STARTS) or row_number == 0:
# If we are playing this section, also
# calculate end time if all tracks are played.
# calculate end time when all tracks are played.
end_time_str = ""
if (
track_sequence.current
@ -1323,9 +1331,8 @@ class PlaylistModel(QAbstractTableModel):
unplayed_count += 1
duration += row_rat.duration
# We should only get here if there were no rows in section (ie,
# this was row zero)
return Config.SUBTOTAL_ON_ROW_ZERO
# We should never get here
raise ApplicationError("Error in section_subtotal_header()")
def selection_is_sortable(self, row_numbers: list[int]) -> bool:
"""
@ -1559,7 +1566,7 @@ class PlaylistModel(QAbstractTableModel):
"<br>".join(
[
a.lastplayed.strftime(Config.LAST_PLAYED_TOOLTIP_DATE_FORMAT)
for a in reversed(playdates)
for a in playdates
]
)
)

View File

@ -109,7 +109,7 @@ class PlaylistDelegate(QStyledItemDelegate):
if self.current_editor:
editor = self.current_editor
else:
if index.column() == QueryCol.INTRO.value:
if index.column() == Col.INTRO.value:
editor = QDoubleSpinBox(parent)
editor.setDecimals(1)
editor.setSingleStep(0.1)
@ -245,7 +245,7 @@ class PlaylistDelegate(QStyledItemDelegate):
self.original_model_data = self.base_model.data(
edit_index, Qt.ItemDataRole.EditRole
)
if index.column() == QueryCol.INTRO.value:
if index.column() == Col.INTRO.value:
if self.original_model_data.value():
editor.setValue(self.original_model_data.value() / 1000)
else:
@ -343,7 +343,7 @@ class PlaylistTab(QTableView):
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)

View File

@ -4,30 +4,30 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import cast
from typing import Optional
import datetime as dt
# PyQt imports
from PyQt6.QtCore import (
QAbstractTableModel,
QModelIndex,
QRegularExpression,
QSortFilterProxyModel,
Qt,
QVariant,
)
from PyQt6.QtGui import (
QBrush,
QColor,
QFont,
)
# Third party imports
from sqlalchemy.orm.session import Session
# import snoop # type: ignore
# App imports
from classes import (
ApplicationError,
Filter,
QueryCol,
)
from config import Config
@ -35,6 +35,7 @@ from helpers import (
file_is_unreadable,
get_relative_date,
ms_to_mmss,
show_warning,
)
from log import log
from models import db, Playdates
@ -46,7 +47,7 @@ class QueryRow:
artist: str
bitrate: int
duration: int
lastplayed: dt.datetime
lastplayed: Optional[dt.datetime]
path: str
title: str
track_id: int
@ -62,26 +63,24 @@ class QuerylistModel(QAbstractTableModel):
"""
def __init__(
self,
playlist_id: int,
) -> None:
log.debug("QuerylistModel.__init__()")
def __init__(self, session: Session, filter: Filter) -> None:
"""
Load query
"""
log.debug(f"QuerylistModel.__init__({filter=})")
self.playlist_id = playlist_id
super().__init__()
self.session = session
self.filter = filter
self.querylist_rows: dict[int, QueryRow] = {}
self._selected_rows: set[int] = set()
with db.Session() as session:
# Populate self.playlist_rows
self.load_data(session)
self.load_data()
def __repr__(self) -> str:
return (
f"<QuerylistModel: playlist_id={self.playlist_id}, {self.rowCount()} rows>"
)
return f"<QuerylistModel: filter={self.filter}, {self.rowCount()} rows>"
def background_role(self, row: int, column: int, qrow: QueryRow) -> QVariant:
"""Return background setting"""
@ -219,52 +218,36 @@ class QuerylistModel(QAbstractTableModel):
return QVariant()
def load_data(
self,
session: db.session,
sql: str = """
SELECT
tracks.*,playdates.lastplayed
FROM
tracks,playdates
WHERE
playdates.track_id=tracks.id
AND tracks.path LIKE '%/Singles/p%'
GROUP BY
tracks.id
HAVING
MAX(playdates.lastplayed) < DATE_SUB(NOW(), INTERVAL 1 YEAR)
ORDER BY tracks.title
;
""",
) -> None:
def load_data(self) -> None:
"""
Load data from user-defined query. Can probably hard-code the SELECT part
to ensure the required fields are returned.
Populate self.querylist_rows
"""
# TODO: Move the SQLAlchemy parts to models later, but for now as proof
# of concept we'll keep it here.
from sqlalchemy import text
# Clear any exsiting rows
self.querylist_rows = {}
row = 0
results = session.execute(text(sql)).mappings().all()
try:
results = Tracks.get_filtered(self.session, self.filter)
for result in results:
if hasattr(result, "lastplayed"):
lastplayed = result["lastplayed"]
else:
lastplayed = None
queryrow = QueryRow(
artist=result["artist"],
bitrate=result["bitrate"],
duration=result["duration"],
lastplayed=result["lastplayed"],
lastplayed=lastplayed,
path=result["path"],
title=result["title"],
track_id=result["id"],
)
self.querylist_rows[row] = queryrow
row += 1
except ApplicationError as e:
show_warning(None, "Query error", f"Error loading query data ({e})")
def rowCount(self, index: QModelIndex = QModelIndex()) -> int:
"""Standard function for view"""

View File

@ -1,193 +0,0 @@
# Standard library imports
from typing import cast, Optional, TYPE_CHECKING
# PyQt imports
from PyQt6.QtCore import (
QTimer,
)
from PyQt6.QtWidgets import (
QAbstractItemView,
QTableView,
)
# Third party imports
# import line_profiler
# App imports
from audacity_controller import AudacityController
from classes import ApplicationError, MusicMusterSignals, PlaylistStyle
from config import Config
from helpers import (
show_warning,
)
from log import log
from models import db, Settings
from querylistmodel import QuerylistModel
if TYPE_CHECKING:
from musicmuster import Window
class QuerylistTab(QTableView):
"""
The querylist view
"""
def __init__(self, musicmuster: "Window", model: QuerylistModel) -> None:
super().__init__()
# Save passed settings
self.musicmuster = musicmuster
self.playlist_id = model.playlist_id
# Set up widget
self.setAlternatingRowColors(True)
self.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
# Set our custom style - this draws the drop indicator across the whole row
self.setStyle(PlaylistStyle())
# We will enable dragging when rows are selected. Disabling it
# here means we can click and drag to select rows.
self.setDragEnabled(False)
# Connect signals
self.signals = MusicMusterSignals()
self.signals.resize_rows_signal.connect(self.resize_rows)
# Selection model
self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
# Enable item editing for checkboxes
self.clicked.connect(self.handle_row_click)
# Set up for Audacity
try:
self.ac: Optional[AudacityController] = AudacityController()
except ApplicationError as e:
self.ac = None
show_warning(self.musicmuster, "Audacity error", str(e))
# Load model, set column widths
self.setModel(model)
self._set_column_widths()
# Stretch last column *after* setting column widths which is
# *much* faster
h_header = self.horizontalHeader()
if h_header:
h_header.sectionResized.connect(self._column_resize)
h_header.setStretchLastSection(True)
# Resize on vertical header click
v_header = self.verticalHeader()
if v_header:
v_header.setMinimumSectionSize(5)
v_header.sectionHandleDoubleClicked.disconnect()
v_header.sectionHandleDoubleClicked.connect(self.resizeRowToContents)
# Setting ResizeToContents causes screen flash on load
self.resize_rows()
# ########## Overridden class functions ##########
def resizeRowToContents(self, row):
super().resizeRowToContents(row)
self.verticalHeader().resizeSection(row, self.sizeHintForRow(row))
def resizeRowsToContents(self):
header = self.verticalHeader()
for row in range(self.model().rowCount()):
hint = self.sizeHintForRow(row)
header.resizeSection(row, hint)
# ########## Custom functions ##########
def clear_selection(self) -> None:
"""Unselect all tracks and reset drag mode"""
self.clearSelection()
# We want to remove the focus from any widget otherwise keyboard
# activity may edit a cell.
fw = self.musicmuster.focusWidget()
if fw:
fw.clearFocus()
self.setDragEnabled(False)
def _column_resize(self, column_number: int, _old: int, _new: int) -> None:
"""
Called when column width changes. Save new width to database.
"""
log.debug(f"_column_resize({column_number=}, {_old=}, {_new=}")
header = self.horizontalHeader()
if not header:
return
# Resize rows if necessary
self.resizeRowsToContents()
with db.Session() as session:
attr_name = f"querylist_col_{column_number}_width"
record = Settings.get_setting(session, attr_name)
record.f_int = self.columnWidth(column_number)
session.commit()
def handle_row_click(self, index):
self.model().toggle_row_selection(index.row())
self.clearSelection()
def model(self) -> QuerylistModel:
"""
Override return type to keep mypy happy in this module
"""
return cast(QuerylistModel, super().model())
def resize_rows(self, playlist_id: Optional[int] = None) -> None:
"""
If playlist_id is us, resize rows
"""
log.debug(f"resize_rows({playlist_id=}) {self.playlist_id=}")
if playlist_id and playlist_id != self.playlist_id:
return
# Suggestion from phind.com
def resize_row(row, count=1):
row_count = self.model().rowCount()
for todo in range(count):
if row < row_count:
self.resizeRowToContents(row)
row += 1
if row < row_count:
QTimer.singleShot(0, lambda: resize_row(row, count))
# Start resizing from row 0, 10 rows at a time
QTimer.singleShot(0, lambda: resize_row(0, Config.RESIZE_ROW_CHUNK_SIZE))
def _set_column_widths(self) -> None:
"""Column widths from settings"""
log.debug("_set_column_widths()")
header = self.horizontalHeader()
if not header:
return
# Last column is set to stretch so ignore it here
with db.Session() as session:
for column_number in range(header.count() - 1):
attr_name = f"querylist_col_{column_number}_width"
record = Settings.get_setting(session, attr_name)
if record.f_int is not None:
self.setColumnWidth(column_number, record.f_int)
else:
self.setColumnWidth(column_number, Config.DEFAULT_COLUMN_WIDTH)
def tab_live(self) -> None:
"""Noop for query tabs"""
return

94
app/ui/dlgQuery.ui Normal file
View File

@ -0,0 +1,94 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>queryDialog</class>
<widget class="QDialog" name="queryDialog">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>762</width>
<height>686</height>
</rect>
</property>
<property name="windowTitle">
<string>Query</string>
</property>
<widget class="QTableView" name="tableView">
<property name="geometry">
<rect>
<x>10</x>
<y>65</y>
<width>741</width>
<height>561</height>
</rect>
</property>
</widget>
<widget class="QLabel" name="label">
<property name="geometry">
<rect>
<x>20</x>
<y>10</y>
<width>61</width>
<height>24</height>
</rect>
</property>
<property name="text">
<string>Query:</string>
</property>
</widget>
<widget class="QComboBox" name="cboQuery">
<property name="geometry">
<rect>
<x>80</x>
<y>10</y>
<width>221</width>
<height>32</height>
</rect>
</property>
</widget>
<widget class="QPushButton" name="btnAddTracks">
<property name="geometry">
<rect>
<x>530</x>
<y>640</y>
<width>102</width>
<height>36</height>
</rect>
</property>
<property name="text">
<string>Add &amp;tracks</string>
</property>
</widget>
<widget class="QLabel" name="lblDescription">
<property name="geometry">
<rect>
<x>330</x>
<y>10</y>
<width>401</width>
<height>46</height>
</rect>
</property>
<property name="text">
<string>TextLabel</string>
</property>
<property name="alignment">
<set>Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop</set>
</property>
</widget>
<widget class="QPushButton" name="pushButton">
<property name="geometry">
<rect>
<x>650</x>
<y>640</y>
<width>102</width>
<height>36</height>
</rect>
</property>
<property name="text">
<string>Close</string>
</property>
</widget>
</widget>
<resources/>
<connections/>
</ui>

45
app/ui/dlgQuery_ui.py Normal file
View File

@ -0,0 +1,45 @@
# Form implementation generated from reading ui file 'app/ui/dlgQuery.ui'
#
# Created by: PyQt6 UI code generator 6.8.1
#
# 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.
from PyQt6 import QtCore, QtGui, QtWidgets
class Ui_queryDialog(object):
def setupUi(self, queryDialog):
queryDialog.setObjectName("queryDialog")
queryDialog.resize(762, 686)
self.tableView = QtWidgets.QTableView(parent=queryDialog)
self.tableView.setGeometry(QtCore.QRect(10, 65, 741, 561))
self.tableView.setObjectName("tableView")
self.label = QtWidgets.QLabel(parent=queryDialog)
self.label.setGeometry(QtCore.QRect(20, 10, 61, 24))
self.label.setObjectName("label")
self.cboQuery = QtWidgets.QComboBox(parent=queryDialog)
self.cboQuery.setGeometry(QtCore.QRect(80, 10, 221, 32))
self.cboQuery.setObjectName("cboQuery")
self.btnAddTracks = QtWidgets.QPushButton(parent=queryDialog)
self.btnAddTracks.setGeometry(QtCore.QRect(530, 640, 102, 36))
self.btnAddTracks.setObjectName("btnAddTracks")
self.lblDescription = QtWidgets.QLabel(parent=queryDialog)
self.lblDescription.setGeometry(QtCore.QRect(330, 10, 401, 46))
self.lblDescription.setAlignment(QtCore.Qt.AlignmentFlag.AlignLeading|QtCore.Qt.AlignmentFlag.AlignLeft|QtCore.Qt.AlignmentFlag.AlignTop)
self.lblDescription.setObjectName("lblDescription")
self.pushButton = QtWidgets.QPushButton(parent=queryDialog)
self.pushButton.setGeometry(QtCore.QRect(650, 640, 102, 36))
self.pushButton.setObjectName("pushButton")
self.retranslateUi(queryDialog)
QtCore.QMetaObject.connectSlotsByName(queryDialog)
def retranslateUi(self, queryDialog):
_translate = QtCore.QCoreApplication.translate
queryDialog.setWindowTitle(_translate("queryDialog", "Query"))
self.label.setText(_translate("queryDialog", "Query:"))
self.btnAddTracks.setText(_translate("queryDialog", "Add &tracks"))
self.lblDescription.setText(_translate("queryDialog", "TextLabel"))
self.pushButton.setText(_translate("queryDialog", "Close"))

View File

@ -1,6 +1,7 @@
<RCC>
<qresource prefix="icons">
<file>yellow-circle.png</file>
<file>redstar.png</file>
<file>green-circle.png</file>
<file>star.png</file>
<file>star_empty.png</file>

File diff suppressed because it is too large Load Diff

View File

@ -1143,7 +1143,7 @@ padding-left: 8px;</string>
</action>
<action name="actionOpenPlaylist">
<property name="text">
<string>Open &amp;playlist...</string>
<string>O&amp;pen...</string>
</property>
</action>
<action name="actionNewPlaylist">

View File

@ -7,7 +7,7 @@
<x>0</x>
<y>0</y>
<width>1249</width>
<height>499</height>
<height>538</height>
</rect>
</property>
<property name="windowTitle">

View File

@ -764,7 +764,7 @@ class Ui_MainWindow(object):
)
self.actionE_xit.setText(_translate("MainWindow", "E&xit"))
self.actionTest.setText(_translate("MainWindow", "&Test"))
self.actionOpenPlaylist.setText(_translate("MainWindow", "Open &playlist..."))
self.actionOpenPlaylist.setText(_translate("MainWindow", "O&pen..."))
self.actionNewPlaylist.setText(_translate("MainWindow", "&New..."))
self.actionTestFunction.setText(_translate("MainWindow", "&Test function"))
self.actionSkipToFade.setText(
@ -847,4 +847,4 @@ class Ui_MainWindow(object):
from infotabs import InfoTabs
from pyqtgraph import PlotWidget
from pyqtgraph import PlotWidget # type: ignore

View File

@ -1 +0,0 @@
env.py.DEBUG

27
migrations/env.py Normal file
View File

@ -0,0 +1,27 @@
from importlib import import_module
from alembic import context
from alchemical.alembic.env import run_migrations
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# import the application's Alchemical instance
try:
import_mod, db_name = config.get_main_option('alchemical_db', '').split(
':')
db = getattr(import_module(import_mod), db_name)
except (ModuleNotFoundError, AttributeError):
raise ValueError(
'Could not import the Alchemical database instance. '
'Ensure that the alchemical_db setting in alembic.ini is correct.'
)
# run the migration engine
# The dictionary provided as second argument includes options to pass to the
# Alembic context. For details on what other options are available, see
# https://alembic.sqlalchemy.org/en/latest/autogenerate.html
run_migrations(db, {
'render_as_batch': True,
'compare_type': True,
})

View File

@ -1,28 +0,0 @@
from importlib import import_module
from alembic import context
from alchemical.alembic.env import run_migrations
# Load Alembic configuration
config = context.config
try:
# Import the Alchemical database instance as specified in alembic.ini
import_mod, db_name = config.get_main_option('alchemical_db', '').split(':')
db = getattr(import_module(import_mod), db_name)
print(f"Successfully loaded Alchemical database instance: {db}")
# Use the metadata associated with the Alchemical instance
metadata = db.Model.metadata
print(f"Metadata tables detected: {metadata.tables.keys()}") # Debug output
except (ModuleNotFoundError, AttributeError) as e:
raise ValueError(
'Could not import the Alchemical database instance or access metadata. '
'Ensure that the alchemical_db setting in alembic.ini is correct and '
'that the Alchemical instance is correctly configured.'
) from e
# Run migrations with metadata
run_migrations(db, {
'render_as_batch': True,
'compare_type': True,
})

View File

@ -1,27 +0,0 @@
from importlib import import_module
from alembic import context
from alchemical.alembic.env import run_migrations
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# import the application's Alchemical instance
try:
import_mod, db_name = config.get_main_option('alchemical_db', '').split(
':')
db = getattr(import_module(import_mod), db_name)
except (ModuleNotFoundError, AttributeError):
raise ValueError(
'Could not import the Alchemical database instance. '
'Ensure that the alchemical_db setting in alembic.ini is correct.'
)
# run the migration engine
# The dictionary provided as second argument includes options to pass to the
# Alembic context. For details on what other options are available, see
# https://alembic.sqlalchemy.org/en/latest/autogenerate.html
run_migrations(db, {
'render_as_batch': True,
'compare_type': True,
})

View File

@ -1,52 +0,0 @@
"""Add data for query playlists
Revision ID: 014f2d4c88a5
Revises: 33c04e3c12c8
Create Date: 2024-12-30 14:23:36.924478
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '014f2d4c88a5'
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! ###
op.create_table('queries',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('query', sa.String(length=2048), nullable=False),
sa.Column('playlist_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['playlist_id'], ['playlists.id'], ),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('queries', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_queries_playlist_id'), ['playlist_id'], unique=False)
# ### end Alembic commands ###
def downgrade_() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('queries', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_queries_playlist_id'))
op.drop_table('queries')
# ### end Alembic commands ###

View File

@ -1,16 +1,16 @@
"""Index for notesolours substring
"""add favouirit to playlists
Revision ID: c76e865ccb85
Revision ID: 04df697e40cd
Revises: 33c04e3c12c8
Create Date: 2025-02-07 18:21:01.760057
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 = 'c76e865ccb85'
revision = '04df697e40cd'
down_revision = '33c04e3c12c8'
branch_labels = None
depends_on = None
@ -30,39 +30,29 @@ def downgrade(engine_name: str) -> None:
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('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')
with op.batch_alter_table('playlist_rows', schema=None) as batch_op:
batch_op.drop_constraint('playlist_rows_ibfk_1', type_='foreignkey')
batch_op.create_foreign_key(None, 'tracks', ['track_id'], ['id'], ondelete='CASCADE')
with op.batch_alter_table('queries', schema=None) as batch_op:
batch_op.drop_constraint('fk_queries_playlist_id_playlists', type_='foreignkey')
batch_op.create_foreign_key(None, 'playlists', ['playlist_id'], ['id'], ondelete='CASCADE')
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('queries', schema=None) as batch_op:
batch_op.drop_constraint(None, type_='foreignkey')
batch_op.create_foreign_key('fk_queries_playlist_id_playlists', 'playlists', ['playlist_id'], ['id'])
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.drop_constraint(None, type_='foreignkey')
batch_op.create_foreign_key('playlist_rows_ibfk_1', 'tracks', ['track_id'], ['id'])
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'])
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 ###