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

View File

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

View File

@ -18,6 +18,7 @@ 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()
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

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

View File

@ -22,6 +22,9 @@ filters:
# module-name: # module-name:
# - function-name-1 # - function-name-1
# - function-name-2 # - function-name-2
musicmuster:
- update_clocks
- play_next
handlers: handlers:
stderr: 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, delete,
func, func,
select, select,
text,
update, update,
) )
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError, ProgrammingError
from sqlalchemy.orm.exc import NoResultFound 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.orm.session import Session
from sqlalchemy.engine.row import RowMapping
# App imports # App imports
from classes import ApplicationError
from config import Config from config import Config
from dbmanager import DatabaseManager from dbmanager import DatabaseManager
import dbtables 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 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 # Database classes
class NoteColours(dbtables.NoteColoursTable): class NoteColours(dbtables.NoteColoursTable):
def __init__( def __init__(
@ -128,13 +142,13 @@ class Playdates(dbtables.PlaydatesTable):
) -> Sequence["Playdates"]: ) -> Sequence["Playdates"]:
""" """
Return a list of the last limit playdates for this track, sorted Return a list of the last limit playdates for this track, sorted
earliest to latest. latest to earliest.
""" """
return session.scalars( return session.scalars(
Playdates.select() Playdates.select()
.where(Playdates.track_id == track_id) .where(Playdates.track_id == track_id)
.order_by(Playdates.lastplayed.asc()) .order_by(Playdates.lastplayed.desc())
.limit(limit) .limit(limit)
).all() ).all()
@ -179,12 +193,18 @@ class Playdates(dbtables.PlaydatesTable):
class Playlists(dbtables.PlaylistsTable): 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.name = name
self.last_used = dt.datetime.now() self.last_used = dt.datetime.now()
session.add(self) session.add(self)
session.commit() session.commit()
# If a template is specified, copy from it
if template_id:
PlaylistRows.copy_playlist(session, template_id, self.id)
@staticmethod @staticmethod
def clear_tabs(session: Session, playlist_ids: list[int]) -> None: def clear_tabs(session: Session, playlist_ids: list[int]) -> None:
""" """
@ -201,26 +221,6 @@ class Playlists(dbtables.PlaylistsTable):
self.open = False self.open = False
session.commit() 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: def delete(self, session: Session) -> None:
""" """
Delete playlist Delete playlist
@ -235,23 +235,10 @@ class Playlists(dbtables.PlaylistsTable):
return session.scalars( return session.scalars(
select(cls) select(cls)
.filter( .filter(cls.is_template.is_(False))
cls.is_template.is_(False),
~cls.query.has()
)
.order_by(cls.last_used.desc()) .order_by(cls.last_used.desc())
).all() ).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 @classmethod
def get_all_templates(cls, session: Session) -> Sequence["Playlists"]: def get_all_templates(cls, session: Session) -> Sequence["Playlists"]:
"""Returns a list of all templates ordered by name""" """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) select(cls).where(cls.is_template.is_(True)).order_by(cls.name)
).all() ).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 @classmethod
def get_closed(cls, session: Session) -> Sequence["Playlists"]: def get_closed(cls, session: 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"""
@ -269,7 +269,6 @@ class Playlists(dbtables.PlaylistsTable):
.filter( .filter(
cls.open.is_(False), cls.open.is_(False),
cls.is_template.is_(False), cls.is_template.is_(False),
~cls.query.has()
) )
.order_by(cls.last_used.desc()) .order_by(cls.last_used.desc())
).all() ).all()
@ -281,13 +280,7 @@ class Playlists(dbtables.PlaylistsTable):
""" """
return session.scalars( return session.scalars(
select(cls) select(cls).where(cls.open.is_(True)).order_by(cls.tab)
.where(
cls.open.is_(True),
~cls.query.has()
)
.order_by(cls.tab)
).all() ).all()
def mark_open(self) -> None: def mark_open(self) -> None:
@ -321,7 +314,7 @@ class Playlists(dbtables.PlaylistsTable):
) -> None: ) -> None:
"""Save passed playlist as new template""" """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: if not template or not template.id:
return return
@ -331,26 +324,6 @@ class Playlists(dbtables.PlaylistsTable):
PlaylistRows.copy_playlist(session, playlist_id, template.id) 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): class PlaylistRows(dbtables.PlaylistRowsTable):
def __init__( def __init__(
self, self,
@ -636,7 +609,7 @@ class PlaylistRows(dbtables.PlaylistRowsTable):
class Settings(dbtables.SettingsTable): class Settings(dbtables.SettingsTable):
def __init__(self, session: Session, name: str): def __init__(self, session: Session, name: str) -> None:
self.name = name self.name = name
session.add(self) session.add(self)
session.commit() session.commit()
@ -664,7 +637,7 @@ class Tracks(dbtables.TracksTable):
fade_at: int, fade_at: int,
silence_at: int, silence_at: int,
bitrate: int, bitrate: int,
): ) -> None:
self.path = path self.path = path
self.title = title self.title = title
self.artist = artist 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 # Third party imports
from sqlalchemy.orm.session import Session
import obswebsocket # type: ignore import obswebsocket # type: ignore
# import snoop # type: ignore # import snoop # type: ignore
# App imports # App imports
from classes import ( from classes import (
ApplicationError,
Col, Col,
MusicMusterSignals, MusicMusterSignals,
) )
@ -73,12 +75,14 @@ class PlaylistModel(QAbstractTableModel):
def __init__( def __init__(
self, self,
playlist_id: int, playlist_id: int,
is_template: bool,
*args: Optional[QObject], *args: Optional[QObject],
**kwargs: Optional[QObject], **kwargs: Optional[QObject],
) -> None: ) -> None:
log.debug("PlaylistModel.__init__()") log.debug("PlaylistModel.__init__()")
self.playlist_id = playlist_id self.playlist_id = playlist_id
self.is_template = is_template
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.playlist_rows: dict[int, RowAndTrack] = {} self.playlist_rows: dict[int, RowAndTrack] = {}
@ -497,7 +501,7 @@ class PlaylistModel(QAbstractTableModel):
""" """
if not index.isValid(): if not index.isValid():
return Qt.ItemFlag.NoItemFlags return Qt.ItemFlag.ItemIsDropEnabled
default = ( default = (
Qt.ItemFlag.ItemIsEnabled Qt.ItemFlag.ItemIsEnabled
@ -771,7 +775,7 @@ class PlaylistModel(QAbstractTableModel):
return None return None
def load_data(self, session: db.session) -> None: def load_data(self, session: Session) -> None:
""" """
Same as refresh data, but only used when creating playslit. Same as refresh data, but only used when creating playslit.
Distinguishes profile time between initial load and other Distinguishes profile time between initial load and other
@ -1060,7 +1064,7 @@ class PlaylistModel(QAbstractTableModel):
# Update display # Update display
self.invalidate_row(track_sequence.previous.row_number) self.invalidate_row(track_sequence.previous.row_number)
def refresh_data(self, session: db.session) -> None: def refresh_data(self, session: Session) -> None:
""" """
Populate self.playlist_rows with playlist data Populate self.playlist_rows with playlist data
@ -1278,13 +1282,17 @@ class PlaylistModel(QAbstractTableModel):
unplayed_count: int = 0 unplayed_count: int = 0
duration: 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 # Show subtotal
for row_number in range(rat.row_number - 1, -1, -1): for row_number in range(rat.row_number - 1, -1, -1):
row_rat = self.playlist_rows[row_number] row_rat = self.playlist_rows[row_number]
if self.is_header_row(row_number): if self.is_header_row(row_number) or row_number == 0:
if row_rat.note.endswith(Config.SECTION_STARTS): if row_rat.note.endswith(Config.SECTION_STARTS) or row_number == 0:
# If we are playing this section, also # 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 = "" end_time_str = ""
if ( if (
track_sequence.current track_sequence.current
@ -1323,9 +1331,8 @@ class PlaylistModel(QAbstractTableModel):
unplayed_count += 1 unplayed_count += 1
duration += row_rat.duration duration += row_rat.duration
# We should only get here if there were no rows in section (ie, # We should never get here
# this was row zero) raise ApplicationError("Error in section_subtotal_header()")
return Config.SUBTOTAL_ON_ROW_ZERO
def selection_is_sortable(self, row_numbers: list[int]) -> bool: def selection_is_sortable(self, row_numbers: list[int]) -> bool:
""" """
@ -1559,7 +1566,7 @@ class PlaylistModel(QAbstractTableModel):
"<br>".join( "<br>".join(
[ [
a.lastplayed.strftime(Config.LAST_PLAYED_TOOLTIP_DATE_FORMAT) 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: if self.current_editor:
editor = self.current_editor editor = self.current_editor
else: else:
if index.column() == QueryCol.INTRO.value: if index.column() == Col.INTRO.value:
editor = QDoubleSpinBox(parent) editor = QDoubleSpinBox(parent)
editor.setDecimals(1) editor.setDecimals(1)
editor.setSingleStep(0.1) editor.setSingleStep(0.1)
@ -245,7 +245,7 @@ class PlaylistDelegate(QStyledItemDelegate):
self.original_model_data = self.base_model.data( self.original_model_data = self.base_model.data(
edit_index, Qt.ItemDataRole.EditRole edit_index, Qt.ItemDataRole.EditRole
) )
if index.column() == QueryCol.INTRO.value: if index.column() == Col.INTRO.value:
if self.original_model_data.value(): if self.original_model_data.value():
editor.setValue(self.original_model_data.value() / 1000) editor.setValue(self.original_model_data.value() / 1000)
else: else:
@ -343,7 +343,7 @@ class PlaylistTab(QTableView):
Override closeEditor to enable play controls and update display. Override closeEditor to enable play controls and update display.
""" """
self.musicmuster.action_Clear_selection.setEnabled(True) self.musicmuster.enable_escape(True)
super(PlaylistTab, self).closeEditor(editor, hint) super(PlaylistTab, self).closeEditor(editor, hint)

View File

@ -4,30 +4,30 @@ from __future__ import annotations
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from typing import cast from typing import Optional
import datetime as dt import datetime as dt
# PyQt imports # PyQt imports
from PyQt6.QtCore import ( from PyQt6.QtCore import (
QAbstractTableModel, QAbstractTableModel,
QModelIndex, QModelIndex,
QRegularExpression,
QSortFilterProxyModel,
Qt, Qt,
QVariant, QVariant,
) )
from PyQt6.QtGui import ( from PyQt6.QtGui import (
QBrush,
QColor, QColor,
QFont, QFont,
) )
# Third party imports # Third party imports
from sqlalchemy.orm.session import Session
# import snoop # type: ignore # import snoop # type: ignore
# App imports # App imports
from classes import ( from classes import (
ApplicationError,
Filter,
QueryCol, QueryCol,
) )
from config import Config from config import Config
@ -35,6 +35,7 @@ from helpers import (
file_is_unreadable, file_is_unreadable,
get_relative_date, get_relative_date,
ms_to_mmss, ms_to_mmss,
show_warning,
) )
from log import log from log import log
from models import db, Playdates from models import db, Playdates
@ -46,7 +47,7 @@ class QueryRow:
artist: str artist: str
bitrate: int bitrate: int
duration: int duration: int
lastplayed: dt.datetime lastplayed: Optional[dt.datetime]
path: str path: str
title: str title: str
track_id: int track_id: int
@ -62,26 +63,24 @@ class QuerylistModel(QAbstractTableModel):
""" """
def __init__( def __init__(self, session: Session, filter: Filter) -> None:
self, """
playlist_id: int, Load query
) -> None: """
log.debug("QuerylistModel.__init__()")
log.debug(f"QuerylistModel.__init__({filter=})")
self.playlist_id = playlist_id
super().__init__() super().__init__()
self.session = session
self.filter = filter
self.querylist_rows: dict[int, QueryRow] = {} self.querylist_rows: dict[int, QueryRow] = {}
self._selected_rows: set[int] = set() self._selected_rows: set[int] = set()
with db.Session() as session: self.load_data()
# Populate self.playlist_rows
self.load_data(session)
def __repr__(self) -> str: def __repr__(self) -> str:
return ( return f"<QuerylistModel: filter={self.filter}, {self.rowCount()} rows>"
f"<QuerylistModel: playlist_id={self.playlist_id}, {self.rowCount()} rows>"
)
def background_role(self, row: int, column: int, qrow: QueryRow) -> QVariant: def background_role(self, row: int, column: int, qrow: QueryRow) -> QVariant:
"""Return background setting""" """Return background setting"""
@ -219,52 +218,36 @@ class QuerylistModel(QAbstractTableModel):
return QVariant() return QVariant()
def load_data( def load_data(self) -> None:
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:
""" """
Load data from user-defined query. Can probably hard-code the SELECT part Populate self.querylist_rows
to ensure the required fields are returned.
""" """
# 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 # Clear any exsiting rows
self.querylist_rows = {} self.querylist_rows = {}
row = 0 row = 0
results = session.execute(text(sql)).mappings().all() try:
for result in results: results = Tracks.get_filtered(self.session, self.filter)
queryrow = QueryRow( for result in results:
artist=result["artist"], if hasattr(result, "lastplayed"):
bitrate=result["bitrate"], lastplayed = result["lastplayed"]
duration=result["duration"], else:
lastplayed=result["lastplayed"], lastplayed = None
path=result["path"], queryrow = QueryRow(
title=result["title"], artist=result["artist"],
track_id=result["id"], bitrate=result["bitrate"],
) duration=result["duration"],
self.querylist_rows[row] = queryrow lastplayed=lastplayed,
row += 1 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: def rowCount(self, index: QModelIndex = QModelIndex()) -> int:
"""Standard function for view""" """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> <RCC>
<qresource prefix="icons"> <qresource prefix="icons">
<file>yellow-circle.png</file> <file>yellow-circle.png</file>
<file>redstar.png</file>
<file>green-circle.png</file> <file>green-circle.png</file>
<file>star.png</file> <file>star.png</file>
<file>star_empty.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>
<action name="actionOpenPlaylist"> <action name="actionOpenPlaylist">
<property name="text"> <property name="text">
<string>Open &amp;playlist...</string> <string>O&amp;pen...</string>
</property> </property>
</action> </action>
<action name="actionNewPlaylist"> <action name="actionNewPlaylist">

View File

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

View File

@ -764,7 +764,7 @@ class Ui_MainWindow(object):
) )
self.actionE_xit.setText(_translate("MainWindow", "E&xit")) self.actionE_xit.setText(_translate("MainWindow", "E&xit"))
self.actionTest.setText(_translate("MainWindow", "&Test")) 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.actionNewPlaylist.setText(_translate("MainWindow", "&New..."))
self.actionTestFunction.setText(_translate("MainWindow", "&Test function")) self.actionTestFunction.setText(_translate("MainWindow", "&Test function"))
self.actionSkipToFade.setText( self.actionSkipToFade.setText(
@ -847,4 +847,4 @@ class Ui_MainWindow(object):
from infotabs import InfoTabs 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 Revises: 33c04e3c12c8
Create Date: 2025-02-07 18:21:01.760057 Create Date: 2025-02-22 20:20:45.030024
""" """
from alembic import op from alembic import op
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision = 'c76e865ccb85' revision = '04df697e40cd'
down_revision = '33c04e3c12c8' down_revision = '33c04e3c12c8'
branch_labels = None branch_labels = None
depends_on = None depends_on = None
@ -30,39 +30,29 @@ def downgrade(engine_name: str) -> None:
def upgrade_() -> None: def upgrade_() -> None:
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('notecolours', schema=None) as batch_op: 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) 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: with op.batch_alter_table('playlist_rows', schema=None) as batch_op:
batch_op.drop_constraint('playlist_rows_ibfk_1', type_='foreignkey') 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: with op.batch_alter_table('playlists', schema=None) as batch_op:
batch_op.drop_constraint('fk_queries_playlist_id_playlists', type_='foreignkey') batch_op.add_column(sa.Column('favourite', sa.Boolean(), nullable=False))
batch_op.create_foreign_key(None, 'playlists', ['playlist_id'], ['id'], ondelete='CASCADE')
# ### end Alembic commands ### # ### end Alembic commands ###
def downgrade_() -> None: def downgrade_() -> None:
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('queries', schema=None) as batch_op: with op.batch_alter_table('playlists', schema=None) as batch_op:
batch_op.drop_constraint(None, type_='foreignkey') batch_op.drop_column('favourite')
batch_op.create_foreign_key('fk_queries_playlist_id_playlists', 'playlists', ['playlist_id'], ['id'])
with op.batch_alter_table('playlist_rows', schema=None) as batch_op: 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']) 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: with op.batch_alter_table('notecolours', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_notecolours_substring')) batch_op.drop_index(batch_op.f('ix_notecolours_substring'))
batch_op.drop_column('strip_substring')
# ### end Alembic commands ### # ### end Alembic commands ###