diff --git a/app/classes.py b/app/classes.py index c625165..6909986 100644 --- a/app/classes.py +++ b/app/classes.py @@ -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 @@ -14,23 +14,16 @@ from PyQt6.QtCore import ( pyqtSignal, QObject, ) +from PyQt6.QtWidgets import ( + QProxyStyle, + QStyle, + QStyleOption, +) # App imports -class Col(Enum): - START_GAP = 0 - TITLE = auto() - ARTIST = auto() - INTRO = auto() - DURATION = auto() - START_TIME = auto() - END_TIME = auto() - LAST_PLAYED = auto() - BITRATE = auto() - NOTE = auto() - - +# Define singleton first as it's needed below def singleton(cls): """ Make a class a Singleton class (see @@ -53,11 +46,6 @@ def singleton(cls): return wrapper_singleton -class FileErrors(NamedTuple): - path: str - error: str - - class ApplicationError(Exception): """ Custom exception @@ -72,6 +60,37 @@ class AudioMetadata(NamedTuple): fade_at: int = 0 +class Col(Enum): + START_GAP = 0 + TITLE = auto() + ARTIST = auto() + INTRO = auto() + DURATION = auto() + START_TIME = auto() + END_TIME = auto() + LAST_PLAYED = auto() + BITRATE = auto() + NOTE = auto() + + +class FileErrors(NamedTuple): + path: str + error: str + + +@dataclass +class Filter: + version: int = 1 + path_type: str = "contains" + path: str = "" + last_played_number: int = 0 + last_played_comparator: str = "before" + last_played_unit: str = "years" + duration_type: str = "longer than" + duration_number: int = 0 + duration_unit: str = "minutes" + + @singleton @dataclass class MusicMusterSignals(QObject): @@ -100,6 +119,32 @@ class MusicMusterSignals(QObject): super().__init__() +class PlaylistStyle(QProxyStyle): + def drawPrimitive(self, element, option, painter, widget=None): + """ + Draw a line across the entire row rather than just the column + we're hovering over. + """ + if ( + element == QStyle.PrimitiveElement.PE_IndicatorItemViewItemDrop + and not option.rect.isNull() + ): + option_new = QStyleOption(option) + option_new.rect.setLeft(0) + if widget: + option_new.rect.setRight(widget.width()) + option = option_new + super().drawPrimitive(element, option, painter, widget) + + +class QueryCol(Enum): + TITLE = 0 + ARTIST = auto() + DURATION = auto() + LAST_PLAYED = auto() + BITRATE = auto() + + class Tags(NamedTuple): artist: str = "" title: str = "" diff --git a/app/config.py b/app/config.py index e7463b4..5d5ef6c 100644 --- a/app/config.py +++ b/app/config.py @@ -31,6 +31,7 @@ class Config(object): COLOUR_NORMAL_TAB = "#000000" COLOUR_NOTES_PLAYLIST = "#b8daff" COLOUR_ODD_PLAYLIST = "#f2f2f2" + COLOUR_QUERYLIST_SELECTED = "#d3ffd3" COLOUR_UNREADABLE = "#dc3545" COLOUR_WARNING_TIMER = "#ffc107" DBFS_SILENCE = -50 @@ -38,6 +39,7 @@ class Config(object): DISPLAY_SQL = False DO_NOT_IMPORT = "Do not import" ENGINE_OPTIONS = dict(pool_pre_ping=True) + # ENGINE_OPTIONS = dict(pool_pre_ping=True, echo=True) EPOCH = dt.datetime(1970, 1, 1) ERRORS_FROM = ["noreply@midnighthax.com"] ERRORS_TO = ["kae@midnighthax.com"] @@ -48,6 +50,19 @@ class Config(object): FADEOUT_DB = -10 FADEOUT_SECONDS = 5 FADEOUT_STEPS_PER_SECOND = 5 + FILTER_DURATION_LONGER = "longer than" + FILTER_DURATION_MINUTES = "minutes" + FILTER_DURATION_SECONDS = "seconds" + FILTER_DURATION_SHORTER = "shorter than" + FILTER_PATH_CONTAINS = "contains" + FILTER_PATH_EXCLUDING = "excluding" + FILTER_PLAYED_COMPARATOR_ANYTIME = "Any time" + FILTER_PLAYED_COMPARATOR_BEFORE = "before" + FILTER_PLAYED_COMPARATOR_NEVER = "never" + FILTER_PLAYED_DAYS = "days" + FILTER_PLAYED_MONTHS = "months" + FILTER_PLAYED_WEEKS = "weeks" + FILTER_PLAYED_YEARS = "years" FUZZYMATCH_MINIMUM_LIST = 60.0 FUZZYMATCH_MINIMUM_SELECT_ARTIST = 80.0 FUZZYMATCH_MINIMUM_SELECT_TITLE = 80.0 @@ -86,6 +101,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" diff --git a/app/dbmanager.py b/app/dbmanager.py index 9f8c2ca..e80d6fd 100644 --- a/app/dbmanager.py +++ b/app/dbmanager.py @@ -18,7 +18,8 @@ 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() + # Database managed by Alembic so no create_all() required + # self.db.create_all() DatabaseManager.__instance = self else: raise Exception("Attempted to create a second DatabaseManager instance") diff --git a/app/dbtables.py b/app/dbtables.py index 4aa97f6..5d1952e 100644 --- a/app/dbtables.py +++ b/app/dbtables.py @@ -1,6 +1,8 @@ # Standard library imports from typing import Optional +from dataclasses import asdict import datetime as dt +import json # PyQt imports @@ -13,13 +15,37 @@ from sqlalchemy import ( String, ) from sqlalchemy.ext.associationproxy import association_proxy +from sqlalchemy.ext.hybrid import hybrid_property +from sqlalchemy.engine.interfaces import Dialect from sqlalchemy.orm import ( Mapped, mapped_column, relationship, ) +from sqlalchemy.types import TypeDecorator, TEXT # App imports +from classes import Filter + + +class JSONEncodedDict(TypeDecorator): + """ + Custom JSON Type for MariaDB (since native JSON type is just LONGTEXT) + """ + + impl = TEXT + + def process_bind_param(self, value: dict | None, dialect: Dialect) -> str | None: + """Convert Python dictionary to JSON string before saving.""" + if value is None: + return None + return json.dumps(value, default=lambda o: o.__dict__) + + def process_result_value(self, value: str | None, dialect: Dialect) -> dict | None: + """Convert JSON string back to Python dictionary after retrieval.""" + if value is None: + return None + return json.loads(value) # Database classes @@ -48,7 +74,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", @@ -105,6 +131,9 @@ class PlaylistRowsTable(Model): playlist: Mapped[PlaylistsTable] = relationship(back_populates="rows") track_id: Mapped[Optional[int]] = mapped_column(ForeignKey("tracks.id", ondelete="CASCADE")) + track_id: Mapped[Optional[int]] = mapped_column( + ForeignKey("tracks.id", ondelete="CASCADE") + ) track: Mapped["TracksTable"] = relationship( "TracksTable", back_populates="playlistrows", @@ -121,6 +150,31 @@ class PlaylistRowsTable(Model): ) +class QueriesTable(Model): + __tablename__ = "queries" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(String(128), nullable=False) + _filter_data: Mapped[dict | None] = mapped_column("filter_data", JSONEncodedDict, nullable=False) + favourite: Mapped[bool] = mapped_column(Boolean, nullable=False, index=False, default=False) + + def _get_filter(self) -> Filter: + """Convert stored JSON dictionary to a Filter object.""" + if isinstance(self._filter_data, dict): + return Filter(**self._filter_data) + return Filter() # Default object if None or invalid data + + def _set_filter(self, value: Filter | None) -> None: + """Convert a Filter object to JSON before storing.""" + self._filter_data = asdict(value) if isinstance(value, Filter) else None + + # Single definition of `filter` + filter = property(_get_filter, _set_filter) + + def __repr__(self) -> str: + return f"" + + class SettingsTable(Model): """Manage settings""" @@ -144,7 +198,7 @@ class TracksTable(Model): id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) artist: Mapped[str] = mapped_column(String(256), index=True) - bitrate: Mapped[Optional[int]] = mapped_column(default=None) + bitrate: Mapped[int] = mapped_column(default=None) duration: Mapped[int] = mapped_column(index=True) fade_at: Mapped[int] = mapped_column(index=False) intro: Mapped[Optional[int]] = mapped_column(default=None) diff --git a/app/helpers.py b/app/helpers.py index 0747af3..2971c08 100644 --- a/app/helpers.py +++ b/app/helpers.py @@ -10,7 +10,7 @@ import ssl import tempfile # PyQt imports -from PyQt6.QtWidgets import QMainWindow, QMessageBox, QWidget +from PyQt6.QtWidgets import QInputDialog, QMainWindow, QMessageBox, QWidget # Third party imports from mutagen.flac import FLAC # type: ignore @@ -150,6 +150,23 @@ def get_audio_metadata(filepath: str) -> AudioMetadata: ) +def get_name(prompt: str, default: str = "") -> str | None: + """Get a name from the user""" + + dlg = QInputDialog() + dlg.setInputMode(QInputDialog.InputMode.TextInput) + dlg.setLabelText(prompt) + while True: + if default: + dlg.setTextValue(default) + dlg.resize(500, 100) + ok = dlg.exec() + if ok: + return dlg.textValue() + + return None + + def get_relative_date( past_date: Optional[dt.datetime], reference_date: Optional[dt.datetime] = None ) -> str: diff --git a/app/menu.yaml b/app/menu.yaml index 239d5ce..4e728a0 100644 --- a/app/menu.yaml +++ b/app/menu.yaml @@ -4,8 +4,10 @@ menus: - text: "Save as Template" handler: "save_as_template" - text: "Manage Templates" - handler: "manage_templates" + handler: "manage_templates_wrapper" - separator: true + - text: "Manage Queries" + handler: "manage_queries_wrapper" - separator: true - text: "Exit" handler: "close" diff --git a/app/models.py b/app/models.py index 238e71a..e3a756b 100644 --- a/app/models.py +++ b/app/models.py @@ -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 from sqlalchemy.orm.session import Session +from sqlalchemy.engine.row import RowMapping # App imports +from classes import ApplicationError, Filter 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__( @@ -114,10 +128,15 @@ class NoteColours(dbtables.NoteColoursTable): class Playdates(dbtables.PlaydatesTable): - def __init__(self, session: Session, track_id: int) -> None: + def __init__( + self, session: Session, track_id: int, when: Optional[dt.datetime] = None + ) -> None: """Record that track was played""" - self.lastplayed = dt.datetime.now() + if not when: + self.lastplayed = dt.datetime.now() + else: + self.lastplayed = when self.track_id = track_id session.add(self) session.commit() @@ -207,14 +226,6 @@ class Playlists(dbtables.PlaylistsTable): self.open = False session.commit() - def delete(self, session: Session) -> None: - """ - Delete playlist - """ - - session.execute(delete(Playlists).where(Playlists.id == self.id)) - session.commit() - @classmethod def get_all(cls, session: Session) -> Sequence["Playlists"]: """Returns a list of all playlists ordered by last use""" @@ -239,10 +250,7 @@ class Playlists(dbtables.PlaylistsTable): return session.scalars( select(cls) - .where( - cls.is_template.is_(True), - cls.favourite.is_(True) - ) + .where(cls.is_template.is_(True), cls.favourite.is_(True)) .order_by(cls.name) ).all() @@ -594,6 +602,37 @@ class PlaylistRows(dbtables.PlaylistRowsTable): session.connection().execute(stmt, sqla_map) +class Queries(dbtables.QueriesTable): + def __init__( + self, + session: Session, + name: str, + filter: dbtables.Filter, + favourite: bool = False, + ) -> None: + """Create new query""" + + self.name = name + self.filter = filter + self.favourite = favourite + session.add(self) + session.commit() + + @classmethod + def get_all(cls, session: Session) -> Sequence["Queries"]: + """Returns a list of all queries ordered by name""" + + return session.scalars(select(cls).order_by(cls.name)).all() + + @classmethod + def get_favourites(cls, session: Session) -> Sequence["Queries"]: + """Returns a list of favourite queries ordered by name""" + + return session.scalars( + select(cls).where(cls.favourite.is_(True)).order_by(cls.name) + ).all() + + class Settings(dbtables.SettingsTable): def __init__(self, session: Session, name: str) -> None: self.name = name @@ -678,6 +717,77 @@ class Tracks(dbtables.TracksTable): .all() ) + @classmethod + def get_filtered_tracks( + cls, session: Session, filter: Filter + ) -> Sequence["Tracks"]: + """ + Return tracks matching filter + """ + + query = select(cls) + + # Path specification + if filter.path: + if filter.path_type == "contains": + query = query.where(cls.path.ilike(f"%{filter.path}%")) + elif filter.path_type == "excluding": + query = query.where(cls.path.notilike(f"%{filter.path}%")) + else: + raise ApplicationError(f"Can't process filter path ({filter=})") + + # Duration specification + seconds_duration = filter.duration_number + if filter.duration_unit == Config.FILTER_DURATION_MINUTES: + seconds_duration *= 60 + elif filter.duration_unit != Config.FILTER_DURATION_SECONDS: + raise ApplicationError(f"Can't process filter duration ({filter=})") + + if filter.duration_type == Config.FILTER_DURATION_LONGER: + query = query.where(cls.duration >= seconds_duration) + elif filter.duration_unit == Config.FILTER_DURATION_SHORTER: + query = query.where(cls.duration <= seconds_duration) + else: + raise ApplicationError(f"Can't process filter duration type ({filter=})") + + # Process comparator + if filter.last_played_comparator == Config.FILTER_PLAYED_COMPARATOR_NEVER: + # Select tracks that have never been played + query = query.outerjoin(Playdates, cls.id == Playdates.track_id).where( + Playdates.id.is_(None) + ) + else: + # Last played specification + now = dt.datetime.now() + # Set sensible default, and correct for Config.FILTER_PLAYED_COMPARATOR_ANYTIME + before = now + # If not ANYTIME, set 'before' appropriates + if filter.last_played_comparator != Config.FILTER_PLAYED_COMPARATOR_ANYTIME: + if filter.last_played_unit == Config.FILTER_PLAYED_DAYS: + before = now - dt.timedelta(days=filter.last_played_number) + elif filter.last_played_unit == Config.FILTER_PLAYED_WEEKS: + before = now - dt.timedelta(days=7 * filter.last_played_number) + elif filter.last_played_unit == Config.FILTER_PLAYED_MONTHS: + before = now - dt.timedelta(days=30 * filter.last_played_number) + elif filter.last_played_unit == Config.FILTER_PLAYED_YEARS: + before = now - dt.timedelta(days=365 * filter.last_played_number) + + subquery = ( + select( + Playdates.track_id, + func.max(Playdates.lastplayed).label("max_last_played"), + ) + .group_by(Playdates.track_id) + .subquery() + ) + query = query.join(subquery, Tracks.id == subquery.c.track_id).where( + subquery.c.max_last_played < before + ) + + records = session.scalars(query).unique().all() + + return records + @classmethod def get_by_path(cls, session: Session, path: str) -> Optional["Tracks"]: """ diff --git a/app/musicmuster.py b/app/musicmuster.py index 173da9c..61fe8bd 100755 --- a/app/musicmuster.py +++ b/app/musicmuster.py @@ -20,6 +20,7 @@ from PyQt6.QtCore import ( Qt, QTime, QTimer, + QVariant, ) from PyQt6.QtGui import ( QAction, @@ -32,6 +33,7 @@ from PyQt6.QtGui import ( QShortcut, ) from PyQt6.QtWidgets import ( + QAbstractItemView, QApplication, QCheckBox, QComboBox, @@ -46,6 +48,8 @@ from PyQt6.QtWidgets import ( QMenu, QMessageBox, QPushButton, + QSpinBox, + QTableView, QTableWidget, QTableWidgetItem, QVBoxLayout, @@ -60,18 +64,20 @@ import stackprinter # type: ignore # App imports from classes import ( ApplicationError, + Filter, MusicMusterSignals, TrackInfo, ) from config import Config from dialogs import TrackSelectDialog from file_importer import FileImporter -from helpers import file_is_unreadable +from helpers import file_is_unreadable, get_name from log import log -from models import db, Playdates, PlaylistRows, Playlists, Settings, Tracks +from models import db, Playdates, PlaylistRows, Playlists, Queries, Settings, Tracks from music_manager import RowAndTrack, track_sequence from playlistmodel import PlaylistModel, PlaylistProxyModel from playlists import PlaylistTab +from querylistmodel import QuerylistModel from ui import icons_rc # noqa F401 from ui.dlg_SelectPlaylist_ui import Ui_dlgSelectPlaylist # type: ignore from ui.downloadcsv_ui import Ui_DateSelect # type: ignore @@ -166,6 +172,162 @@ class EditDeleteDialog(QDialog): self.reject() +class FilterDialog(QDialog): + def __init__(self, name: str, filter: Filter) -> None: + super().__init__() + self.filter = filter + + self.setWindowTitle("Filter Settings") + + layout = QVBoxLayout() + + # Name row + name_layout = QHBoxLayout() + name_label = QLabel("Name") + self.name_text = QLineEdit() + self.name_text.setText(name) + name_layout.addWidget(name_label) + name_layout.addWidget(self.name_text) + layout.addLayout(name_layout) + + # Path row + path_layout = QHBoxLayout() + path_label = QLabel("Path") + self.path_combo = QComboBox() + self.path_combo.addItems( + [Config.FILTER_PATH_CONTAINS, Config.FILTER_PATH_EXCLUDING] + ) + for idx in range(self.path_combo.count()): + if self.path_combo.itemText(idx) == filter.path_type: + self.path_combo.setCurrentIndex(idx) + break + self.path_text = QLineEdit() + if filter.path: + self.path_text.setText(filter.path) + path_layout.addWidget(path_label) + path_layout.addWidget(self.path_combo) + path_layout.addWidget(self.path_text) + layout.addLayout(path_layout) + + # Last played row + last_played_layout = QHBoxLayout() + last_played_label = QLabel("Last played") + self.last_played_combo = QComboBox() + self.last_played_combo.addItems( + [ + Config.FILTER_PLAYED_COMPARATOR_BEFORE, + Config.FILTER_PLAYED_COMPARATOR_NEVER, + Config.FILTER_PLAYED_COMPARATOR_ANYTIME, + ] + ) + for idx in range(self.last_played_combo.count()): + if self.last_played_combo.itemText(idx) == filter.last_played_comparator: + self.last_played_combo.setCurrentIndex(idx) + break + + self.last_played_spinbox = QSpinBox() + self.last_played_spinbox.setMinimum(0) + self.last_played_spinbox.setMaximum(100) + self.last_played_spinbox.setValue(filter.last_played_number or 0) + + self.last_played_unit = QComboBox() + self.last_played_unit.addItems( + [ + Config.FILTER_PLAYED_YEARS, + Config.FILTER_PLAYED_MONTHS, + Config.FILTER_PLAYED_WEEKS, + Config.FILTER_PLAYED_DAYS, + ] + ) + for idx in range(self.last_played_unit.count()): + if self.last_played_unit.itemText(idx) == filter.last_played_unit: + self.last_played_unit.setCurrentIndex(idx) + break + + last_played_ago_label = QLabel("ago") + + last_played_layout.addWidget(last_played_label) + last_played_layout.addWidget(self.last_played_combo) + last_played_layout.addWidget(self.last_played_spinbox) + last_played_layout.addWidget(self.last_played_unit) + last_played_layout.addWidget(last_played_ago_label) + + layout.addLayout(last_played_layout) + + # Duration row + duration_layout = QHBoxLayout() + duration_label = QLabel("Duration") + self.duration_combo = QComboBox() + self.duration_combo.addItems( + [Config.FILTER_DURATION_LONGER, Config.FILTER_DURATION_SHORTER] + ) + for idx in range(self.duration_combo.count()): + if self.duration_combo.itemText(idx) == filter.duration_type: + self.duration_combo.setCurrentIndex(idx) + break + + self.duration_spinbox = QSpinBox() + self.duration_spinbox.setMinimum(0) + self.duration_spinbox.setMaximum(1000) + self.duration_spinbox.setValue(filter.duration_number) + + self.duration_unit = QComboBox() + self.duration_unit.addItems( + [Config.FILTER_DURATION_MINUTES, Config.FILTER_DURATION_SECONDS] + ) + self.duration_unit.setCurrentText(Config.FILTER_DURATION_MINUTES) + for idx in range(self.duration_unit.count()): + if self.duration_unit.itemText(idx) == filter.duration_unit: + self.duration_unit.setCurrentIndex(idx) + break + + duration_layout.addWidget(duration_label) + duration_layout.addWidget(self.duration_combo) + duration_layout.addWidget(self.duration_spinbox) + duration_layout.addWidget(self.duration_unit) + + layout.addLayout(duration_layout) + + # Buttons + button_layout = QHBoxLayout() + self.ok_button = QPushButton("OK") + self.cancel_button = QPushButton("Cancel") + self.cancel_button.clicked.connect(self.reject) + self.ok_button.clicked.connect(self.ok_clicked) + button_layout.addWidget(self.ok_button) + button_layout.addWidget(self.cancel_button) + layout.addLayout(button_layout) + + self.setLayout(layout) + + # Connect signals + self.last_played_combo.currentIndexChanged.connect( + self.toggle_last_played_controls + ) + + self.toggle_last_played_controls() + + def toggle_last_played_controls(self): + disabled = self.last_played_combo.currentText() == "never" + self.last_played_spinbox.setDisabled(disabled) + self.last_played_unit.setDisabled(disabled) + + def ok_clicked(self) -> None: + """ + Update filter to match selections + """ + self.filter.path_type = self.path_combo.currentText() + self.filter.path = self.path_text.text() + self.filter.last_played_number = self.last_played_spinbox.value() + self.filter.last_played_comparator = self.last_played_combo.currentText() + self.filter.last_played_unit = self.last_played_unit.currentText() + self.filter.duration_type = self.duration_combo.currentText() + self.filter.duration_number = self.duration_spinbox.value() + self.filter.duration_unit = self.duration_unit.currentText() + + self.accept() + + @dataclass class ItemlistItem: id: int @@ -174,18 +336,14 @@ class ItemlistItem: class ItemlistManager(QDialog): - def __init__( - self, items: list[ItemlistItem], callbacks: ItemlistManagerCallbacks - ) -> None: + def __init__(self) -> None: super().__init__() self.setWindowTitle("Item Manager") self.setMinimumSize(600, 400) - self.items = items - self.callbacks = callbacks - layout = QVBoxLayout(self) - self.table = QTableWidget(len(items), 2, self) + self.table = QTableWidget(self) + self.table.setColumnCount(2) self.table.setHorizontalHeaderLabels(["Item", "Actions"]) hh = self.table.horizontalHeader() if not hh: @@ -194,8 +352,6 @@ class ItemlistManager(QDialog): self.table.setColumnWidth(0, 288) self.table.setColumnWidth(1, 300) - self.populate_table() - layout.addWidget(self.table) button_layout = QHBoxLayout() @@ -209,8 +365,10 @@ class ItemlistManager(QDialog): layout.addLayout(button_layout) - def populate_table(self) -> None: + def populate_table(self, items: list[ItemlistItem]) -> None: """Populates the table with items and action buttons.""" + + self.items = items self.table.setRowCount(len(self.items)) for row, item in enumerate(self.items): @@ -249,25 +407,35 @@ class ItemlistManager(QDialog): self.table.setCellWidget(row, 1, widget) def delete_item(self, item_id: int) -> None: - self.callbacks.delete(item_id) + """Subclass must implement this method""" + raise NotImplementedError def edit_item(self, item_id: int) -> None: - self.callbacks.edit(item_id) + """Subclass must implement this method""" + raise NotImplementedError + + def new_item(self) -> None: + """Subclass must implement this method""" + raise NotImplementedError def rename_item(self, item_id: int) -> None: - new_name = self.callbacks.rename(item_id) - if not new_name: - return - # Rename item in list + """Subclass must implement this method""" + raise NotImplementedError + + def change_text(self, item_id: int, new_text: str) -> None: + """ + Update text for one row + """ + for row in range(self.table.rowCount()): item = self.table.item(row, 0) if item and self.items[row].id == item_id: - item.setText(new_name) - self.items[row].name = new_name + item.setText(new_text) + self.items[row].name = new_text break def toggle_favourite(self, item_id: int, checked: bool) -> None: - self.callbacks.favourite(item_id, checked) + """Subclass must udpate database if required""" for row in range(self.table.rowCount()): item = self.table.item(row, 0) @@ -280,8 +448,231 @@ class ItemlistManager(QDialog): self.items[row].favourite = checked break + +class ManageQueries(ItemlistManager): + """ + Delete / edit queries + """ + + def __init__(self, session: Session, musicmuster: Window) -> None: + super().__init__() + + self.session = session + self.musicmuster = musicmuster + self.refresh_table() + self.exec() + + def refresh_table(self) -> None: + """ + Update table in widget + """ + + # Build a list of queries + query_list: list[ItemlistItem] = [] + + for query in Queries.get_all(self.session): + query_list.append( + ItemlistItem(name=query.name, id=query.id, favourite=query.favourite) + ) + + self.populate_table(query_list) + + def delete_item(self, query_id: int) -> None: + """delete query""" + + query = self.session.get(Queries, query_id) + if not query: + raise ApplicationError( + f"manage_template.delete({query_id=}) can't load query" + ) + if helpers.ask_yes_no( + "Delete query", + f"Delete query '{query.name}': " "Are you sure?", + ): + log.debug(f"manage_queries: delete {query=}") + self.session.delete(query) + self.session.commit() + + self.refresh_table() + + def _edit_item(self, query: Queries) -> None: + """Edit query""" + + dlg = FilterDialog(query.name, query.filter) + if dlg.exec(): + query.filter = dlg.filter + query.name = dlg.name_text.text() + self.session.commit() + + def edit_item(self, query_id: int) -> None: + """Edit query""" + + query = self.session.get(Queries, query_id) + if not query: + raise ApplicationError( + f"manage_template.edit_item({query_id=}) can't load query" + ) + return self._edit_item(query) + + def toggle_favourite(self, query_id: int, favourite: bool) -> None: + """Mark query as (not) favourite""" + + query = self.session.get(Queries, query_id) + if not query: + return + query.favourite = favourite + self.session.commit() + def new_item(self) -> None: - self.callbacks.new_item() + """Create new query""" + + query_name = get_name(prompt="New query name:") + if not query_name: + return + + query = Queries(self.session, query_name, Filter()) + self._edit_item(query) + self.refresh_table() + + def rename_item(self, query_id: int) -> None: + """rename query""" + + query = self.session.get(Queries, query_id) + if not query: + raise ApplicationError( + f"manage_template.delete({query_id=}) can't load query" + ) + new_name = get_name(prompt="New query name", default=query.name) + if not new_name: + return + + query.name = new_name + self.session.commit() + + self.change_text(query_id, new_name) + + +class ManageTemplates(ItemlistManager): + """ + Delete / edit templates + """ + + def __init__(self, session: Session, musicmuster: Window) -> None: + super().__init__() + + self.session = session + self.musicmuster = musicmuster + self.refresh_table() + self.exec() + + def refresh_table(self) -> None: + """ + Update table in widget + """ + + # Build a list of templates + template_list: list[ItemlistItem] = [] + + for template in Playlists.get_all_templates(self.session): + template_list.append( + ItemlistItem( + name=template.name, id=template.id, favourite=template.favourite + ) + ) + + self.populate_table(template_list) + + def delete_item(self, template_id: int) -> None: + """delete template""" + + template = self.session.get(Playlists, template_id) + if not template: + raise ApplicationError( + f"manage_template.delete({template_id=}) can't load template" + ) + if helpers.ask_yes_no( + "Delete template", + f"Delete template '{template.name}': Are you sure?", + ): + # If template is currently open, re-check + open_idx = self.musicmuster.get_tab_index_for_playlist(template_id) + if open_idx: + if not helpers.ask_yes_no( + "Delete open template", + f"Template '{template.name}' is currently open. Really delete?", + ): + return + else: + self.musicmuster.playlist_section.tabPlaylist.removeTab(open_idx) + + log.debug(f"manage_templates: delete {template=}") + self.session.delete(template) + self.session.commit() + + def edit_item(self, template_id: int) -> None: + """Edit template""" + + template = self.session.get(Playlists, template_id) + if not template: + raise ApplicationError( + f"manage_template.edit({template_id=}) can't load template" + ) + # Simply load the template as a playlist. Any changes + # made will persist + self.musicmuster._open_playlist(template, is_template=True) + + def toggle_favourite(self, template_id: int, favourite: bool) -> None: + """Mark template as (not) favourite""" + + template = self.session.get(Playlists, template_id) + if not template: + return + template.favourite = favourite + self.session.commit() + + def new_item( + self, + ) -> None: + """Create new template""" + + # Get base template + template_id = self.musicmuster.solicit_template_to_use( + self.session, template_prompt="New template based upon:" + ) + if template_id is None: + return + + # Get new template name + name = self.musicmuster.get_playlist_name( + self.session, default="", prompt="New template name:" + ) + if not name: + return + + # Create playlist for template and mark is as a template + template = self.musicmuster._create_playlist(self.session, name, template_id) + template.is_template = True + self.session.commit() + + # Open it for editing + self.musicmuster._open_playlist(template, is_template=True) + + def rename_item(self, template_id: int) -> None: + """rename template""" + + template = self.session.get(Playlists, template_id) + if not template: + raise ApplicationError( + f"manage_template.delete({template_id=}) can't load template" + ) + new_name = self.musicmuster.get_playlist_name(self.session, template.name) + if not new_name: + return + + template.name = new_name + self.session.commit() + + self.change_text(template_id, new_name) @dataclass @@ -404,6 +795,230 @@ class PreviewManager: self.start_time = None +class QueryDialog(QDialog): + """Dialog box to handle selecting track from a query""" + + def __init__(self, session: Session, default: int = 0) -> None: + super().__init__() + self.session = session + self.default = default + + # Build a list of (query-name, playlist-id) tuples + self.selected_tracks: list[int] = [] + + self.query_list: list[tuple[str, int]] = [] + self.query_list.append((Config.NO_QUERY_NAME, 0)) + for query in Queries.get_all(self.session): + self.query_list.append((query.name, query.id)) + + self.setWindowTitle("Query Selector") + + # Create label + query_label = QLabel("Query:") + + # Top layout (Query label, combo box, and info label) + top_layout = QHBoxLayout() + + # Query label + query_label = QLabel("Query:") + top_layout.addWidget(query_label) + + # Combo Box with fixed width + self.combo_box = QComboBox() + # self.combo_box.setFixedWidth(150) # Adjust as necessary for 20 characters + for text, id_ in self.query_list: + self.combo_box.addItem(text, id_) + top_layout.addWidget(self.combo_box) + + # Table (middle part) + self.table_view = QTableView() + self.table_view.setSelectionMode( + QAbstractItemView.SelectionMode.ExtendedSelection + ) + self.table_view.setSelectionBehavior( + QAbstractItemView.SelectionBehavior.SelectRows + ) + self.table_view.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) + self.table_view.setAlternatingRowColors(True) + self.table_view.setVerticalScrollMode( + QAbstractItemView.ScrollMode.ScrollPerPixel + ) + self.table_view.clicked.connect(self.handle_row_click) + + # Bottom layout (buttons) + bottom_layout = QHBoxLayout() + bottom_layout.addStretch() # Push buttons to the right + + self.add_tracks_button = QPushButton("Add tracks") + self.add_tracks_button.setEnabled(False) # Disabled by default + self.add_tracks_button.clicked.connect(self.add_tracks_clicked) + bottom_layout.addWidget(self.add_tracks_button) + + self.cancel_button = QPushButton("Cancel") + self.cancel_button.clicked.connect(self.cancel_clicked) + bottom_layout.addWidget(self.cancel_button) + + # Main layout + main_layout = QVBoxLayout() + main_layout.addLayout(top_layout) + main_layout.addWidget(self.table_view) + main_layout.addLayout(bottom_layout) + + self.combo_box.currentIndexChanged.connect(self.query_changed) + if self.default: + default_idx = self.combo_box.findData(QVariant(self.default)) + self.combo_box.setCurrentIndex(default_idx) + self.path_text = QLineEdit() + self.setLayout(main_layout) + + # Stretch last column *after* setting column widths which is + # *much* faster + h_header = self.table_view.horizontalHeader() + if h_header: + h_header.sectionResized.connect(self._column_resize) + h_header.setStretchLastSection(True) + # Resize on vertical header click + v_header = self.table_view.verticalHeader() + if v_header: + v_header.setMinimumSectionSize(5) + v_header.sectionHandleDoubleClicked.disconnect() + v_header.sectionHandleDoubleClicked.connect( + self.table_view.resizeRowToContents + ) + + self.set_window_size() + self.resizeRowsToContents() + + def add_tracks_clicked(self): + self.selected_tracks = self.table_view.model().get_selected_track_ids() + self.accept() + + def cancel_clicked(self): + self.selected_tracks = [] + self.reject() + + def closeEvent(self, event: QCloseEvent | None) -> None: + """ + Record size and columns + """ + + self.save_sizes() + super().closeEvent(event) + + def accept(self) -> None: + self.save_sizes() + super().accept() + + def reject(self) -> None: + self.save_sizes() + super().reject() + + def save_sizes(self) -> None: + """ + Save window size + """ + + # Save dialog box attributes + attributes_to_save = dict( + querylist_height=self.height(), + querylist_width=self.width(), + querylist_x=self.x(), + querylist_y=self.y(), + ) + for name, value in attributes_to_save.items(): + record = Settings.get_setting(self.session, name) + record.f_int = value + + header = self.table_view.horizontalHeader() + if header is None: + return + column_count = header.count() + if column_count < 2: + return + for column_number in range(column_count - 1): + attr_name = f"querylist_col_{column_number}_width" + record = Settings.get_setting(self.session, attr_name) + record.f_int = self.table_view.columnWidth(column_number) + + self.session.commit() + + def _column_resize(self, column_number: int, _old: int, _new: int) -> None: + """ + Called when column width changes. + """ + + header = self.table_view.horizontalHeader() + if not header: + return + + # Resize rows if necessary + self.resizeRowsToContents() + + def resizeRowsToContents(self): + header = self.table_view.verticalHeader() + model = self.table_view.model() + if model: + for row in range(model.rowCount()): + hint = self.table_view.sizeHintForRow(row) + header.resizeSection(row, hint) + + def query_changed(self, idx: int) -> None: + """ + Called when user selects query + """ + + # Get query id + query_id = self.combo_box.currentData() + query = self.session.get(Queries, query_id) + if not query: + return + + # Create model + base_model = QuerylistModel(self.session, query.filter) + + # Create table + self.table_view.setModel(base_model) + self.set_column_sizes() + + def handle_row_click(self, index): + self.table_view.model().toggle_row_selection(index.row()) + self.table_view.clearSelection() + + # Enable 'Add tracks' button only when a row is selected + selected = self.table_view.model().get_selected_track_ids() + self.add_tracks_button.setEnabled(selected != []) + + def set_window_size(self) -> None: + """Set window sizes""" + + x = Settings.get_setting(self.session, "querylist_x").f_int or 100 + y = Settings.get_setting(self.session, "querylist_y").f_int or 100 + width = Settings.get_setting(self.session, "querylist_width").f_int or 100 + height = Settings.get_setting(self.session, "querylist_height").f_int or 100 + self.setGeometry(x, y, width, height) + + def set_column_sizes(self) -> None: + """Set column sizes""" + + header = self.table_view.horizontalHeader() + if header is None: + return + column_count = header.count() + if column_count < 2: + return + + # Last column is set to stretch so ignore it here + for column_number in range(column_count - 1): + attr_name = f"querylist_col_{column_number}_width" + record = Settings.get_setting(self.session, attr_name) + if record.f_int is not None: + self.table_view.setColumnWidth(column_number, record.f_int) + else: + self.table_view.setColumnWidth( + column_number, Config.DEFAULT_COLUMN_WIDTH + ) + + class SelectPlaylistDialog(QDialog): def __init__(self, parent=None, playlists=None, session=None): super().__init__() @@ -724,6 +1339,10 @@ class Window(QMainWindow): # Dynamically call the correct function items = getattr(self, f"get_{key}_items")() for item in items: + # Check for separator + if "separator" in item and item["separator"]: + submenu.addSeparator() + continue action = QAction(item["text"], self) # Extract handler and arguments @@ -739,7 +1358,7 @@ class Window(QMainWindow): def get_new_playlist_dynamic_submenu_items( self, - ) -> list[dict[str, str | tuple[Session, int]]]: + ) -> list[dict[str, str | tuple[Session, int] | bool]]: """ Return dynamically generated menu items, in this case templates marked as favourite from which to generate a @@ -750,11 +1369,15 @@ class Window(QMainWindow): """ with db.Session() as session: - submenu_items: list[dict[str, str | tuple[Session, int]]] = [ - {"text": "Show all", - "handler": "create_playlist_from_template", - "args": (session, 0) - } + submenu_items: list[dict[str, str | tuple[Session, int] | bool]] = [ + { + "text": "Show all", + "handler": "create_playlist_from_template", + "args": (session, 0), + }, + { + "separator": True, + }, ] templates = Playlists.get_favourite_templates(session) for template in templates: @@ -771,12 +1394,52 @@ class Window(QMainWindow): return submenu_items - def get_query_dynamic_submenu_items(self): - """Returns dynamically generated menu items for Submenu 2.""" - return [ - {"text": "Action Xargs", "handler": "kae", "args": (21,)}, - {"text": "Action Y", "handler": "action_y_handler"}, - ] + def get_query_dynamic_submenu_items( + self, + ) -> list[dict[str, str | tuple[Session, int] | bool]]: + """ + Return dynamically generated menu items, in this case + templates marked as favourite from which to generate a + new playlist. + + The handler is to call show_query with a session + and query_id. + """ + + with db.Session() as session: + submenu_items: list[dict[str, str | tuple[Session, int] | bool]] = [ + { + "text": "Show all", + "handler": "show_query", + "args": (session, 0), + }, + { + "separator": True, + }, + ] + queries = Queries.get_favourites(session) + for query in queries: + submenu_items.append( + { + "text": query.name, + "handler": "show_query", + "args": ( + session, + query.id, + ), + } + ) + + return submenu_items + + def show_query(self, session: Session, query_id: int) -> None: + """ + Show query dialog with query_id selected + """ + + # Keep a reference else it will be gc'd + self.query_dialog = QueryDialog(session, query_id) + self.query_dialog.exec() # # # # # # # # # # Playlist management functions # # # # # # # # # # @@ -835,7 +1498,7 @@ class Window(QMainWindow): else: template_id = selected_template_id - playlist_name = self.solicit_playlist_name(session) + playlist_name = self.get_playlist_name(session) if not playlist_name: return @@ -857,7 +1520,7 @@ class Window(QMainWindow): f"Delete playlist '{playlist.name}': " "Are you sure?", ): if self.close_playlist_tab(): - playlist.delete(session) + session.delete(playlist) session.commit() else: log.error("Failed to retrieve playlist") @@ -900,10 +1563,10 @@ class Window(QMainWindow): session.commit() helpers.show_OK("Template", "Template saved", self) - def solicit_playlist_name( + def get_playlist_name( self, session: Session, default: str = "", prompt: str = "Playlist name:" ) -> Optional[str]: - """Get name of new playlist from user""" + """Get a name from the user""" dlg = QInputDialog(self) dlg.setInputMode(QInputDialog.InputMode.TextInput) @@ -948,6 +1611,24 @@ class Window(QMainWindow): return dlg.selected_id + # # # # # # # # # # Manage templates and queries # # # # # # # # # # + + def manage_queries_wrapper(self): + """ + Simply instantiate the manage_queries class + """ + + with db.Session() as session: + _ = ManageQueries(session, self) + + def manage_templates_wrapper(self): + """ + Simply instantiate the manage_queries class + """ + + with db.Session() as session: + _ = ManageTemplates(session, self) + # # # # # # # # # # Miscellaneous functions # # # # # # # # # # def select_duplicate_rows(self) -> None: @@ -1341,125 +2022,6 @@ class Window(QMainWindow): self.signals.search_wikipedia_signal.emit(track_info.title) - def manage_templates(self) -> None: - """ - Delete / edit templates - """ - - # Define callbacks to handle management options - def delete(template_id: int) -> None: - """delete template""" - - template = session.get(Playlists, template_id) - if not template: - raise ApplicationError( - f"manage_templeate.delete({template_id=}) can't load template" - ) - if helpers.ask_yes_no( - "Delete template", - f"Delete template '{template.name}': " "Are you sure?", - ): - # If template is currently open, re-check - open_idx = self.get_tab_index_for_playlist(template_id) - if open_idx: - if not helpers.ask_yes_no( - "Delete open template", - f"Template '{template.name}' is currently open. Really delete?", - ): - return - else: - self.playlist_section.tabPlaylist.removeTab(open_idx) - - log.info(f"manage_templates: delete {template=}") - template.delete(session) - session.commit() - - def edit(template_id: int) -> None: - """Edit template""" - - template = session.get(Playlists, template_id) - if not template: - raise ApplicationError( - f"manage_templeate.edit({template_id=}) can't load template" - ) - # Simply load the template as a playlist. Any changes - # made will persist - self._open_playlist(template, is_template=True) - - def favourite(template_id: int, favourite: bool) -> None: - """Mark template as (not) favourite""" - - template = session.get(Playlists, template_id) - template.favourite = favourite - session.commit() - - def new_item() -> None: - """Create new template""" - - # Get base template - template_id = self.solicit_template_to_use( - session, template_prompt="New template based upon:" - ) - if template_id is None: - return - - # Get new template name - name = self.solicit_playlist_name( - session, default="", prompt="New template name:" - ) - if not name: - return - - # Create playlist for template and mark is as a template - template = self._create_playlist(session, name, template_id) - template.is_template = True - session.commit() - - # Open it for editing - self._open_playlist(template, is_template=True) - - def rename(template_id: int) -> Optional[str]: - """rename template""" - - template = session.get(Playlists, template_id) - if not template: - raise ApplicationError( - f"manage_templeate.delete({template_id=}) can't load template" - ) - new_name = self.solicit_playlist_name(session, template.name) - if new_name: - template.rename(session, new_name) - idx = self.tabBar.currentIndex() - self.tabBar.setTabText(idx, new_name) - session.commit() - return new_name - - return None - - # Call listitem management dialog to manage templates - callbacks = ItemlistManagerCallbacks( - delete=delete, - edit=edit, - favourite=favourite, - new_item=new_item, - rename=rename, - ) - - # Build a list of templates - template_list: list[ItemlistItem] = [] - - with db.Session() as session: - for template in Playlists.get_all_templates(session): - template_list.append( - ItemlistItem( - name=template.name, id=template.id, favourite=template.favourite - ) - ) - # We need to retain a reference to the dialog box to stop it - # going out of scope and being garbage-collected. - self.dlg = ItemlistManager(template_list, callbacks) - self.dlg.show() - def mark_rows_for_moving(self) -> None: """ Cut rows ready for pasting. @@ -1778,7 +2340,7 @@ class Window(QMainWindow): playlist_id = self.current.playlist_id playlist = session.get(Playlists, playlist_id) if playlist: - new_name = self.solicit_playlist_name(session, playlist.name) + new_name = self.get_playlist_name(session, playlist.name) if new_name: playlist.rename(session, new_name) idx = self.tabBar.currentIndex() @@ -2199,7 +2761,12 @@ class Window(QMainWindow): self.playlist_section.tabPlaylist.setTabIcon( idx, QIcon(Config.PLAYLIST_ICON_CURRENT) ) - elif self.playlist_section.tabPlaylist.widget(idx).model().sourceModel().is_template: + elif ( + self.playlist_section.tabPlaylist.widget(idx) + .model() + .sourceModel() + .is_template + ): self.playlist_section.tabPlaylist.setTabIcon( idx, QIcon(Config.PLAYLIST_ICON_TEMPLATE) ) diff --git a/app/playlists.py b/app/playlists.py index fe5c574..2082b8d 100644 --- a/app/playlists.py +++ b/app/playlists.py @@ -22,10 +22,7 @@ from PyQt6.QtWidgets import ( QFrame, QMenu, QMessageBox, - QProxyStyle, - QStyle, QStyledItemDelegate, - QStyleOption, QStyleOptionViewItem, QTableView, QTableWidgetItem, @@ -38,7 +35,7 @@ import line_profiler # App imports from audacity_controller import AudacityController -from classes import ApplicationError, Col, MusicMusterSignals, TrackInfo +from classes import ApplicationError, Col, MusicMusterSignals, PlaylistStyle, TrackInfo from config import Config from dialogs import TrackSelectDialog from helpers import ( @@ -269,24 +266,6 @@ class PlaylistDelegate(QStyledItemDelegate): editor.setGeometry(option.rect) -class PlaylistStyle(QProxyStyle): - def drawPrimitive(self, element, option, painter, widget=None): - """ - Draw a line across the entire row rather than just the column - we're hovering over. - """ - if ( - element == QStyle.PrimitiveElement.PE_IndicatorItemViewItemDrop - and not option.rect.isNull() - ): - option_new = QStyleOption(option) - option_new.rect.setLeft(0) - if widget: - option_new.rect.setRight(widget.width()) - option = option_new - super().drawPrimitive(element, option, painter, widget) - - class PlaylistTab(QTableView): """ The playlist view diff --git a/app/querylistmodel.py b/app/querylistmodel.py new file mode 100644 index 0000000..995972b --- /dev/null +++ b/app/querylistmodel.py @@ -0,0 +1,288 @@ +# Standard library imports +# Allow forward reference to PlaylistModel +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Optional +import datetime as dt + +# PyQt imports +from PyQt6.QtCore import ( + QAbstractTableModel, + QModelIndex, + Qt, + QVariant, +) +from PyQt6.QtGui import ( + 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 +from helpers import ( + file_is_unreadable, + get_relative_date, + ms_to_mmss, + show_warning, +) +from log import log +from models import db, Playdates, Tracks +from music_manager import RowAndTrack + + +@dataclass +class QueryRow: + artist: str + bitrate: int + duration: int + lastplayed: Optional[dt.datetime] + path: str + title: str + track_id: int + + +class QuerylistModel(QAbstractTableModel): + """ + The Querylist Model + + Used to support query lists. The underlying database is never + updated. We just present tracks that match a query and allow the user + to copy those to a playlist. + + """ + + def __init__(self, session: Session, filter: Filter) -> None: + """ + Load query + """ + + log.debug(f"QuerylistModel.__init__({filter=})") + + super().__init__() + self.session = session + self.filter = filter + + self.querylist_rows: dict[int, QueryRow] = {} + self._selected_rows: set[int] = set() + + self.load_data() + + def __repr__(self) -> str: + return f"" + + def background_role(self, row: int, column: int, qrow: QueryRow) -> QVariant: + """Return background setting""" + + # Unreadable track file + if file_is_unreadable(qrow.path): + return QVariant(QColor(Config.COLOUR_UNREADABLE)) + + # Selected row + if row in self._selected_rows: + return QVariant(QColor(Config.COLOUR_QUERYLIST_SELECTED)) + + # Individual cell colouring + if column == QueryCol.BITRATE.value: + if not qrow.bitrate or qrow.bitrate < Config.BITRATE_LOW_THRESHOLD: + return QVariant(QColor(Config.COLOUR_BITRATE_LOW)) + elif qrow.bitrate < Config.BITRATE_OK_THRESHOLD: + return QVariant(QColor(Config.COLOUR_BITRATE_MEDIUM)) + else: + return QVariant(QColor(Config.COLOUR_BITRATE_OK)) + + return QVariant() + + def columnCount(self, parent: QModelIndex = QModelIndex()) -> int: + """Standard function for view""" + + return len(QueryCol) + + def data( + self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole + ) -> QVariant: + """Return data to view""" + + if not index.isValid() or not (0 <= index.row() < len(self.querylist_rows)): + return QVariant() + + row = index.row() + column = index.column() + # rat for playlist row data as it's used a lot + qrow = self.querylist_rows[row] + + # Dispatch to role-specific functions + dispatch_table: dict[int, Callable] = { + int(Qt.ItemDataRole.BackgroundRole): self.background_role, + int(Qt.ItemDataRole.DisplayRole): self.display_role, + int(Qt.ItemDataRole.ToolTipRole): self.tooltip_role, + } + + if role in dispatch_table: + return QVariant(dispatch_table[role](row, column, qrow)) + + # Document other roles but don't use them + if role in [ + Qt.ItemDataRole.DecorationRole, + Qt.ItemDataRole.EditRole, + Qt.ItemDataRole.FontRole, + Qt.ItemDataRole.ForegroundRole, + Qt.ItemDataRole.InitialSortOrderRole, + Qt.ItemDataRole.SizeHintRole, + Qt.ItemDataRole.StatusTipRole, + Qt.ItemDataRole.TextAlignmentRole, + Qt.ItemDataRole.ToolTipRole, + Qt.ItemDataRole.WhatsThisRole, + ]: + return QVariant() + + # Fall through to no-op + return QVariant() + + def display_role(self, row: int, column: int, qrow: QueryRow) -> QVariant: + """ + Return text for display + """ + + dispatch_table = { + QueryCol.ARTIST.value: QVariant(qrow.artist), + QueryCol.BITRATE.value: QVariant(qrow.bitrate), + QueryCol.DURATION.value: QVariant(ms_to_mmss(qrow.duration)), + QueryCol.LAST_PLAYED.value: QVariant(get_relative_date(qrow.lastplayed)), + QueryCol.TITLE.value: QVariant(qrow.title), + } + if column in dispatch_table: + return dispatch_table[column] + + return QVariant() + + def flags(self, index: QModelIndex) -> Qt.ItemFlag: + """ + Standard model flags + """ + + if not index.isValid(): + return Qt.ItemFlag.NoItemFlags + + return Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable + + def get_selected_track_ids(self) -> list[int]: + """ + Return a list of track_ids from selected tracks + """ + + return [self.querylist_rows[row].track_id for row in self._selected_rows] + + def headerData( + self, + section: int, + orientation: Qt.Orientation, + role: int = Qt.ItemDataRole.DisplayRole, + ) -> QVariant: + """ + Return text for headers + """ + + display_dispatch_table = { + QueryCol.TITLE.value: QVariant(Config.HEADER_TITLE), + QueryCol.ARTIST.value: QVariant(Config.HEADER_ARTIST), + QueryCol.DURATION.value: QVariant(Config.HEADER_DURATION), + QueryCol.LAST_PLAYED.value: QVariant(Config.HEADER_LAST_PLAYED), + QueryCol.BITRATE.value: QVariant(Config.HEADER_BITRATE), + } + + if role == Qt.ItemDataRole.DisplayRole: + if orientation == Qt.Orientation.Horizontal: + return display_dispatch_table[section] + else: + if Config.ROWS_FROM_ZERO: + return QVariant(str(section)) + else: + return QVariant(str(section + 1)) + + elif role == Qt.ItemDataRole.FontRole: + boldfont = QFont() + boldfont.setBold(True) + return QVariant(boldfont) + + return QVariant() + + def load_data(self) -> None: + """ + Populate self.querylist_rows + """ + + # Clear any exsiting rows + self.querylist_rows = {} + row = 0 + + try: + results = Tracks.get_filtered_tracks(self.session, self.filter) + for result in results: + lastplayed = None + if hasattr(result, "playdates"): + pds = result.playdates + if pds: + lastplayed = max([a.lastplayed for a in pds]) + queryrow = QueryRow( + artist=result.artist, + bitrate=result.bitrate or 0, + duration=result.duration, + 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""" + + return len(self.querylist_rows) + + def toggle_row_selection(self, row: int) -> None: + if row in self._selected_rows: + self._selected_rows.discard(row) + else: + self._selected_rows.add(row) + + # Emit dataChanged for the entire row + top_left = self.index(row, 0) + bottom_right = self.index(row, self.columnCount() - 1) + self.dataChanged.emit(top_left, bottom_right, [Qt.ItemDataRole.BackgroundRole]) + + def tooltip_role(self, row: int, column: int, rat: RowAndTrack) -> QVariant: + """ + Return tooltip. Currently only used for last_played column. + """ + + if column != QueryCol.LAST_PLAYED.value: + return QVariant() + with db.Session() as session: + track_id = self.querylist_rows[row].track_id + if not track_id: + return QVariant() + playdates = Playdates.last_playdates(session, track_id) + return QVariant( + "
".join( + [ + a.lastplayed.strftime(Config.LAST_PLAYED_TOOLTIP_DATE_FORMAT) + for a in reversed(playdates) + ] + ) + ) diff --git a/app/ui/dlgQuery.ui b/app/ui/dlgQuery.ui new file mode 100644 index 0000000..f410813 --- /dev/null +++ b/app/ui/dlgQuery.ui @@ -0,0 +1,94 @@ + + + queryDialog + + + + 0 + 0 + 762 + 686 + + + + Query + + + + + 10 + 65 + 741 + 561 + + + + + + + 20 + 10 + 61 + 24 + + + + Query: + + + + + + 80 + 10 + 221 + 32 + + + + + + + 530 + 640 + 102 + 36 + + + + Add &tracks + + + + + + 330 + 10 + 401 + 46 + + + + TextLabel + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + + + + 650 + 640 + 102 + 36 + + + + Close + + + + + + diff --git a/app/ui/dlgQuery_ui.py b/app/ui/dlgQuery_ui.py new file mode 100644 index 0000000..6a29211 --- /dev/null +++ b/app/ui/dlgQuery_ui.py @@ -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")) diff --git a/app/ui/main_window.ui b/app/ui/main_window.ui index 425bc93..53b2b13 100644 --- a/app/ui/main_window.ui +++ b/app/ui/main_window.ui @@ -997,6 +997,9 @@ padding-left: 8px; + + + @@ -1369,6 +1372,16 @@ padding-left: 8px; Import files... + + + Open &querylist... + + + + + Manage querylists... + + diff --git a/app/ui/main_window_ui.py b/app/ui/main_window_ui.py index cad53d2..064376f 100644 --- a/app/ui/main_window_ui.py +++ b/app/ui/main_window_ui.py @@ -1,6 +1,6 @@ # Form implementation generated from reading ui file 'app/ui/main_window.ui' # -# Created by: PyQt6 UI code generator 6.8.0 +# 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. @@ -657,6 +657,10 @@ class Ui_MainWindow(object): self.actionSelect_duplicate_rows.setObjectName("actionSelect_duplicate_rows") self.actionImport_files = QtGui.QAction(parent=MainWindow) self.actionImport_files.setObjectName("actionImport_files") + self.actionOpenQuerylist = QtGui.QAction(parent=MainWindow) + self.actionOpenQuerylist.setObjectName("actionOpenQuerylist") + self.actionManage_querylists = QtGui.QAction(parent=MainWindow) + self.actionManage_querylists.setObjectName("actionManage_querylists") self.menuFile.addSeparator() self.menuFile.addAction(self.actionInsertTrack) self.menuFile.addAction(self.actionRemove) @@ -680,6 +684,9 @@ class Ui_MainWindow(object): self.menuPlaylist.addAction(self.actionRenamePlaylist) self.menuPlaylist.addAction(self.actionDeletePlaylist) self.menuPlaylist.addSeparator() + self.menuPlaylist.addAction(self.actionOpenQuerylist) + self.menuPlaylist.addAction(self.actionManage_querylists) + self.menuPlaylist.addSeparator() self.menuPlaylist.addAction(self.actionSave_as_template) self.menuPlaylist.addAction(self.actionManage_templates) self.menuPlaylist.addSeparator() diff --git a/migrations/versions/4fc2a9a82ab0_create_queries_table.py b/migrations/versions/4fc2a9a82ab0_create_queries_table.py new file mode 100644 index 0000000..073a374 --- /dev/null +++ b/migrations/versions/4fc2a9a82ab0_create_queries_table.py @@ -0,0 +1,47 @@ +"""create queries table + +Revision ID: 4fc2a9a82ab0 +Revises: ab475332d873 +Create Date: 2025-02-26 13:13:25.118489 + +""" +from alembic import op +import sqlalchemy as sa +import dbtables + + +# revision identifiers, used by Alembic. +revision = '4fc2a9a82ab0' +down_revision = 'ab475332d873' +branch_labels = None +depends_on = None + + +def upgrade(engine_name: str) -> None: + globals()["upgrade_%s" % engine_name]() + + +def downgrade(engine_name: str) -> None: + globals()["downgrade_%s" % engine_name]() + + + + + +def upgrade_() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('queries', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('name', sa.String(length=128), nullable=False), + sa.Column('filter_data', dbtables.JSONEncodedDict(), nullable=False), + sa.Column('favourite', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade_() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('queries') + # ### end Alembic commands ### + diff --git a/migrations/versions/ab475332d873_fix_playdates_cascades.py b/migrations/versions/ab475332d873_fix_playdates_cascades.py new file mode 100644 index 0000000..5e5cec8 --- /dev/null +++ b/migrations/versions/ab475332d873_fix_playdates_cascades.py @@ -0,0 +1,46 @@ +"""fix playlist cascades + +Revision ID: ab475332d873 +Revises: 04df697e40cd +Create Date: 2025-02-26 13:11:15.417278 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'ab475332d873' +down_revision = '04df697e40cd' +branch_labels = None +depends_on = None + + +def upgrade(engine_name: str) -> None: + globals()["upgrade_%s" % engine_name]() + + +def downgrade(engine_name: str) -> None: + globals()["downgrade_%s" % engine_name]() + + + + + +def upgrade_() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('playdates', schema=None) as batch_op: + batch_op.drop_constraint('fk_playdates_track_id_tracks', type_='foreignkey') + batch_op.create_foreign_key(None, 'tracks', ['track_id'], ['id'], ondelete='CASCADE') + + # ### end Alembic commands ### + + +def downgrade_() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('playdates', schema=None) as batch_op: + batch_op.drop_constraint(None, type_='foreignkey') + batch_op.create_foreign_key('fk_playdates_track_id_tracks', 'tracks', ['track_id'], ['id']) + + # ### end Alembic commands ### + diff --git a/tests/test_file_importer.py b/tests/test_file_importer.py index feb9ee5..f49e557 100644 --- a/tests/test_file_importer.py +++ b/tests/test_file_importer.py @@ -57,8 +57,8 @@ class MyTestCase(unittest.TestCase): # Create a playlist for all tests playlist_name = "file importer playlist" with db.Session() as session: - playlist = Playlists(session, playlist_name) - cls.widget.create_playlist_tab(playlist) + playlist = Playlists(session=session, name=playlist_name, template_id=0) + cls.widget._open_playlist(playlist) # Create our musicstore cls.import_source = tempfile.mkdtemp(suffix="_MMsource_pytest", dir="/tmp") diff --git a/tests/test_models.py b/tests/test_models.py index b03eef1..20deaee 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -108,7 +108,7 @@ class TestMMModels(unittest.TestCase): TEMPLATE_NAME = "my template" with db.Session() as session: - playlist = Playlists(session, "my playlist") + playlist = Playlists(session, "my playlist", template_id=0) assert playlist # test repr _ = str(playlist) @@ -119,23 +119,18 @@ class TestMMModels(unittest.TestCase): # create template Playlists.save_as_template(session, playlist.id, TEMPLATE_NAME) - # test create template - _ = Playlists.create_playlist_from_template( - session, playlist, "my new name" - ) - # get all templates all_templates = Playlists.get_all_templates(session) assert len(all_templates) == 1 # Save as template creates new playlist assert all_templates[0] != playlist # test delete playlist - playlist.delete(session) + session.delete(playlist) def test_playlist_open_and_close(self): # We need a playlist with db.Session() as session: - playlist = Playlists(session, "my playlist") + playlist = Playlists(session, "my playlist", template_id=0) assert len(Playlists.get_open(session)) == 0 assert len(Playlists.get_closed(session)) == 1 @@ -155,8 +150,8 @@ class TestMMModels(unittest.TestCase): p1_name = "playlist one" p2_name = "playlist two" with db.Session() as session: - playlist1 = Playlists(session, p1_name) - _ = Playlists(session, p2_name) + playlist1 = Playlists(session, p1_name, template_id=0) + _ = Playlists(session, p2_name, template_id=0) all_playlists = Playlists.get_all(session) assert len(all_playlists) == 2 @@ -254,7 +249,7 @@ class TestMMModels(unittest.TestCase): with db.Session() as session: if Playlists.name_is_available(session, PLAYLIST_NAME): - playlist = Playlists(session, PLAYLIST_NAME) + playlist = Playlists(session, PLAYLIST_NAME, template_id=0) assert playlist assert Playlists.name_is_available(session, PLAYLIST_NAME) is False @@ -266,7 +261,7 @@ class TestMMModels(unittest.TestCase): with db.Session() as session: if Playlists.name_is_available(session, PLAYLIST_NAME): - playlist = Playlists(session, PLAYLIST_NAME) + playlist = Playlists(session=session, name=PLAYLIST_NAME, template_id=0) plr = PlaylistRows(session, playlist.id, 1) assert plr @@ -279,7 +274,7 @@ class TestMMModels(unittest.TestCase): with db.Session() as session: if Playlists.name_is_available(session, PLAYLIST_NAME): - playlist = Playlists(session, PLAYLIST_NAME) + playlist = Playlists(session=session, name=PLAYLIST_NAME, template_id=0) plr = PlaylistRows(session, playlist.id, 1) assert plr diff --git a/tests/test_playlistmodel.py b/tests/test_playlistmodel.py index 42778c0..4791d2c 100644 --- a/tests/test_playlistmodel.py +++ b/tests/test_playlistmodel.py @@ -34,8 +34,8 @@ class TestMMMiscTracks(unittest.TestCase): # Create a playlist and model with db.Session() as session: - self.playlist = Playlists(session, PLAYLIST_NAME) - self.model = playlistmodel.PlaylistModel(self.playlist.id) + self.playlist = Playlists(session, PLAYLIST_NAME, template_id=0) + self.model = playlistmodel.PlaylistModel(self.playlist.id, is_template=False) for row in range(len(self.test_tracks)): track_path = self.test_tracks[row % len(self.test_tracks)] @@ -93,9 +93,9 @@ class TestMMMiscNoPlaylist(unittest.TestCase): def test_insert_track_new_playlist(self): # insert a track into a new playlist with db.Session() as session: - playlist = Playlists(session, self.PLAYLIST_NAME) + playlist = Playlists(session, self.PLAYLIST_NAME, template_id=0) # Create a model - model = playlistmodel.PlaylistModel(playlist.id) + model = playlistmodel.PlaylistModel(playlist.id, is_template=False) # test repr _ = str(model) @@ -124,8 +124,8 @@ class TestMMMiscRowMove(unittest.TestCase): db.create_all() with db.Session() as session: - self.playlist = Playlists(session, self.PLAYLIST_NAME) - self.model = playlistmodel.PlaylistModel(self.playlist.id) + self.playlist = Playlists(session, self.PLAYLIST_NAME, template_id=0) + self.model = playlistmodel.PlaylistModel(self.playlist.id, is_template=False) for row in range(self.ROWS_TO_CREATE): self.model.insert_row(proposed_row_number=row, note=str(row)) @@ -318,8 +318,8 @@ class TestMMMiscRowMove(unittest.TestCase): model_src = self.model with db.Session() as session: - playlist_dst = Playlists(session, destination_playlist) - model_dst = playlistmodel.PlaylistModel(playlist_dst.id) + playlist_dst = Playlists(session, destination_playlist, template_id=0) + model_dst = playlistmodel.PlaylistModel(playlist_dst.id, is_template=False) for row in range(self.ROWS_TO_CREATE): model_dst.insert_row(proposed_row_number=row, note=str(row)) @@ -339,8 +339,8 @@ class TestMMMiscRowMove(unittest.TestCase): model_src = self.model with db.Session() as session: - playlist_dst = Playlists(session, destination_playlist) - model_dst = playlistmodel.PlaylistModel(playlist_dst.id) + playlist_dst = Playlists(session, destination_playlist, template_id=0) + model_dst = playlistmodel.PlaylistModel(playlist_dst.id, is_template=False) for row in range(self.ROWS_TO_CREATE): model_dst.insert_row(proposed_row_number=row, note=str(row)) @@ -366,8 +366,8 @@ class TestMMMiscRowMove(unittest.TestCase): model_src = self.model with db.Session() as session: - playlist_dst = Playlists(session, destination_playlist) - model_dst = playlistmodel.PlaylistModel(playlist_dst.id) + playlist_dst = Playlists(session, destination_playlist, template_id=0) + model_dst = playlistmodel.PlaylistModel(playlist_dst.id, is_template=False) for row in range(self.ROWS_TO_CREATE): model_dst.insert_row(proposed_row_number=row, note=str(row)) diff --git a/tests/test_queries.py b/tests/test_queries.py new file mode 100644 index 0000000..1e84a61 --- /dev/null +++ b/tests/test_queries.py @@ -0,0 +1,130 @@ +# Standard library imports +import datetime as dt +import unittest + +# PyQt imports + + +# Third party imports + +# App imports +from app.models import ( + db, + Playdates, + Tracks, +) +from classes import ( + Filter, +) + + +class MyTestCase(unittest.TestCase): + @classmethod + def setUpClass(cls): + """Runs once before any test in this class""" + + db.create_all() + + with db.Session() as session: + # Create some track entries + _ = Tracks(**dict( + session=session, + artist="a", + bitrate=0, + duration=100, + fade_at=0, + path="/alpha/bravo/charlie", + silence_at=0, + start_gap=0, + title="abc" + )) + track2 = Tracks(**dict( + session=session, + artist="a", + bitrate=0, + duration=100, + fade_at=0, + path="/xray/yankee/zulu", + silence_at=0, + start_gap=0, + title="xyz" + )) + track2_id = track2.id + # Add playdates + # Track 2 played just over a year ago + just_over_a_year_ago = dt.datetime.now() - dt.timedelta(days=367) + _ = Playdates(session, track2_id, when=just_over_a_year_ago) + + @classmethod + def tearDownClass(cls): + """Runs once after all tests""" + + db.drop_all() + + def setUp(self): + """Runs before each test""" + + pass + + def tearDown(self): + """Runs after each test""" + + pass + + def test_search_path_1(self): + """Search for unplayed track""" + + filter = Filter(path="alpha", last_played_comparator="never") + + with db.Session() as session: + results = Tracks.get_filtered_tracks(session, filter) + assert len(results) == 1 + assert 'alpha' in results[0].path + + def test_search_path_2(self): + """Search for unplayed track that doesn't exist""" + + filter = Filter(path="xray", last_played_comparator="never") + + with db.Session() as session: + results = Tracks.get_filtered_tracks(session, filter) + assert len(results) == 0 + + def test_played_over_a_year_ago(self): + """Search for tracks played over a year ago""" + + filter = Filter(last_played_unit="years", last_played_number=1) + + with db.Session() as session: + results = Tracks.get_filtered_tracks(session, filter) + assert len(results) == 1 + assert 'zulu' in results[0].path + + def test_played_over_two_years_ago(self): + """Search for tracks played over 2 years ago""" + + filter = Filter(last_played_unit="years", last_played_number=2) + + with db.Session() as session: + results = Tracks.get_filtered_tracks(session, filter) + assert len(results) == 0 + + def test_never_played(self): + """Search for tracks never played""" + + filter = Filter(last_played_comparator="never") + + with db.Session() as session: + results = Tracks.get_filtered_tracks(session, filter) + assert len(results) == 1 + assert 'alpha' in results[0].path + + def test_played_anytime(self): + """Search for tracks played over a year ago""" + + filter = Filter(last_played_comparator="Any time") + + with db.Session() as session: + results = Tracks.get_filtered_tracks(session, filter) + assert len(results) == 1 + assert 'zulu' in results[0].path diff --git a/tests/test_ui.py b/tests/test_ui.py index 2d6f34b..508c700 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -90,8 +90,8 @@ class MyTestCase(unittest.TestCase): playlist_name = "test_init playlist" with db.Session() as session: - playlist = Playlists(session, playlist_name) - self.widget.create_playlist_tab(playlist) + playlist = Playlists(session, playlist_name, template_id=0) + self.widget._open_playlist(playlist, is_template=False) with self.qtbot.waitExposed(self.widget): self.widget.show() @@ -103,8 +103,8 @@ class MyTestCase(unittest.TestCase): playlist_name = "test_save_and_restore playlist" with db.Session() as session: - playlist = Playlists(session, playlist_name) - model = playlistmodel.PlaylistModel(playlist.id) + playlist = Playlists(session, playlist_name, template_id=0) + model = playlistmodel.PlaylistModel(playlist.id, is_template=False) # Add a track with a note model.insert_row( @@ -139,7 +139,7 @@ class MyTestCase(unittest.TestCase): # def test_meta_all_clear(qtbot, session): # # Create playlist -# playlist = models.Playlists(session, "my playlist") +# playlist = models.Playlists(session, "my playlist", template_id=0) # playlist_tab = playlists.PlaylistTab(None, session, playlist.id) # # Add some tracks @@ -167,7 +167,8 @@ class MyTestCase(unittest.TestCase): # def test_meta(qtbot, session): # # Create playlist -# playlist = playlists.Playlists(session, "my playlist") +# playlist = playlists.Playlists(session, "my playlist", +# template_id=0) # playlist_tab = playlists.PlaylistTab(None, session, playlist.id) # # Add some tracks @@ -248,7 +249,7 @@ class MyTestCase(unittest.TestCase): # def test_clear_next(qtbot, session): # # Create playlist -# playlist = models.Playlists(session, "my playlist") +# playlist = models.Playlists(session, "my playlist", template_id=0) # playlist_tab = playlists.PlaylistTab(None, session, playlist.id) # # Add some tracks @@ -274,7 +275,7 @@ class MyTestCase(unittest.TestCase): # # Create playlist and playlist_tab # window = musicmuster.Window() -# playlist = models.Playlists(session, "test playlist") +# playlist = models.Playlists(session, "test playlist", template_id=0) # playlist_tab = playlists.PlaylistTab(window, session, playlist.id) # # Add some tracks @@ -306,7 +307,7 @@ class MyTestCase(unittest.TestCase): # playlist_name = "test playlist" # # Create testing playlist # window = musicmuster.Window() -# playlist = models.Playlists(session, playlist_name) +# playlist = models.Playlists(session, playlist_name, template_id=0) # playlist_tab = playlists.PlaylistTab(window, session, playlist.id) # idx = window.tabPlaylist.addTab(playlist_tab, playlist_name) # window.tabPlaylist.setCurrentIndex(idx)