Merge query tabs

This commit is contained in:
Keith Edmunds 2025-03-05 15:16:24 +00:00
commit f5c77ddffd
21 changed files with 1714 additions and 257 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
@ -14,23 +14,16 @@ from PyQt6.QtCore import (
pyqtSignal, pyqtSignal,
QObject, QObject,
) )
from PyQt6.QtWidgets import (
QProxyStyle,
QStyle,
QStyleOption,
)
# App imports # App imports
class Col(Enum): # Define singleton first as it's needed below
START_GAP = 0
TITLE = auto()
ARTIST = auto()
INTRO = auto()
DURATION = auto()
START_TIME = auto()
END_TIME = auto()
LAST_PLAYED = auto()
BITRATE = auto()
NOTE = auto()
def singleton(cls): def singleton(cls):
""" """
Make a class a Singleton class (see Make a class a Singleton class (see
@ -53,11 +46,6 @@ def singleton(cls):
return wrapper_singleton return wrapper_singleton
class FileErrors(NamedTuple):
path: str
error: str
class ApplicationError(Exception): class ApplicationError(Exception):
""" """
Custom exception Custom exception
@ -72,6 +60,37 @@ class AudioMetadata(NamedTuple):
fade_at: int = 0 fade_at: int = 0
class Col(Enum):
START_GAP = 0
TITLE = auto()
ARTIST = auto()
INTRO = auto()
DURATION = auto()
START_TIME = auto()
END_TIME = auto()
LAST_PLAYED = auto()
BITRATE = auto()
NOTE = auto()
class FileErrors(NamedTuple):
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 @singleton
@dataclass @dataclass
class MusicMusterSignals(QObject): class MusicMusterSignals(QObject):
@ -100,6 +119,32 @@ class MusicMusterSignals(QObject):
super().__init__() 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): class Tags(NamedTuple):
artist: str = "" artist: str = ""
title: str = "" title: str = ""

View File

@ -31,6 +31,7 @@ class Config(object):
COLOUR_NORMAL_TAB = "#000000" COLOUR_NORMAL_TAB = "#000000"
COLOUR_NOTES_PLAYLIST = "#b8daff" COLOUR_NOTES_PLAYLIST = "#b8daff"
COLOUR_ODD_PLAYLIST = "#f2f2f2" COLOUR_ODD_PLAYLIST = "#f2f2f2"
COLOUR_QUERYLIST_SELECTED = "#d3ffd3"
COLOUR_UNREADABLE = "#dc3545" COLOUR_UNREADABLE = "#dc3545"
COLOUR_WARNING_TIMER = "#ffc107" COLOUR_WARNING_TIMER = "#ffc107"
DBFS_SILENCE = -50 DBFS_SILENCE = -50
@ -38,6 +39,7 @@ class Config(object):
DISPLAY_SQL = False DISPLAY_SQL = False
DO_NOT_IMPORT = "Do not import" DO_NOT_IMPORT = "Do not import"
ENGINE_OPTIONS = dict(pool_pre_ping=True) ENGINE_OPTIONS = dict(pool_pre_ping=True)
# ENGINE_OPTIONS = dict(pool_pre_ping=True, echo=True)
EPOCH = dt.datetime(1970, 1, 1) EPOCH = dt.datetime(1970, 1, 1)
ERRORS_FROM = ["noreply@midnighthax.com"] ERRORS_FROM = ["noreply@midnighthax.com"]
ERRORS_TO = ["kae@midnighthax.com"] ERRORS_TO = ["kae@midnighthax.com"]
@ -48,6 +50,19 @@ class Config(object):
FADEOUT_DB = -10 FADEOUT_DB = -10
FADEOUT_SECONDS = 5 FADEOUT_SECONDS = 5
FADEOUT_STEPS_PER_SECOND = 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_LIST = 60.0
FUZZYMATCH_MINIMUM_SELECT_ARTIST = 80.0 FUZZYMATCH_MINIMUM_SELECT_ARTIST = 80.0
FUZZYMATCH_MINIMUM_SELECT_TITLE = 80.0 FUZZYMATCH_MINIMUM_SELECT_TITLE = 80.0
@ -86,6 +101,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"

View File

@ -18,7 +18,8 @@ class DatabaseManager:
def __init__(self, database_url: str, **kwargs: dict) -> None: def __init__(self, database_url: str, **kwargs: dict) -> None:
if DatabaseManager.__instance is None: if DatabaseManager.__instance is None:
self.db = Alchemical(database_url, **kwargs) self.db = Alchemical(database_url, **kwargs)
self.db.create_all() # Database managed by Alembic so no create_all() required
# self.db.create_all()
DatabaseManager.__instance = self DatabaseManager.__instance = self
else: else:
raise Exception("Attempted to create a second DatabaseManager instance") raise Exception("Attempted to create a second DatabaseManager instance")

View File

@ -1,6 +1,8 @@
# Standard library imports # Standard library imports
from typing import Optional from typing import Optional
from dataclasses import asdict
import datetime as dt import datetime as dt
import json
# PyQt imports # PyQt imports
@ -13,13 +15,37 @@ from sqlalchemy import (
String, String,
) )
from sqlalchemy.ext.associationproxy import association_proxy from sqlalchemy.ext.associationproxy import association_proxy
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.engine.interfaces import Dialect
from sqlalchemy.orm import ( from sqlalchemy.orm import (
Mapped, Mapped,
mapped_column, mapped_column,
relationship, relationship,
) )
from sqlalchemy.types import TypeDecorator, TEXT
# App imports # App imports
from classes import Filter
class JSONEncodedDict(TypeDecorator):
"""
Custom JSON Type for MariaDB (since native JSON type is just LONGTEXT)
"""
impl = TEXT
def process_bind_param(self, value: 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 # Database classes
@ -48,7 +74,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",
@ -105,6 +131,9 @@ class PlaylistRowsTable(Model):
playlist: Mapped[PlaylistsTable] = relationship(back_populates="rows") playlist: Mapped[PlaylistsTable] = relationship(back_populates="rows")
track_id: Mapped[Optional[int]] = mapped_column(ForeignKey("tracks.id", ondelete="CASCADE")) track_id: Mapped[Optional[int]] = mapped_column(ForeignKey("tracks.id", ondelete="CASCADE"))
track_id: Mapped[Optional[int]] = mapped_column(
ForeignKey("tracks.id", ondelete="CASCADE")
)
track: Mapped["TracksTable"] = relationship( track: Mapped["TracksTable"] = relationship(
"TracksTable", "TracksTable",
back_populates="playlistrows", back_populates="playlistrows",
@ -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"<QueriesTable(id={self.id}, name={self.name}, filter={self.filter})>"
class SettingsTable(Model): class SettingsTable(Model):
"""Manage settings""" """Manage settings"""
@ -144,7 +198,7 @@ class TracksTable(Model):
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
artist: Mapped[str] = mapped_column(String(256), index=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) duration: Mapped[int] = mapped_column(index=True)
fade_at: Mapped[int] = mapped_column(index=False) fade_at: Mapped[int] = mapped_column(index=False)
intro: Mapped[Optional[int]] = mapped_column(default=None) intro: Mapped[Optional[int]] = mapped_column(default=None)

View File

@ -10,7 +10,7 @@ import ssl
import tempfile import tempfile
# PyQt imports # PyQt imports
from PyQt6.QtWidgets import QMainWindow, QMessageBox, QWidget from PyQt6.QtWidgets import QInputDialog, QMainWindow, QMessageBox, QWidget
# Third party imports # Third party imports
from mutagen.flac import FLAC # type: ignore 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( def get_relative_date(
past_date: Optional[dt.datetime], reference_date: Optional[dt.datetime] = None past_date: Optional[dt.datetime], reference_date: Optional[dt.datetime] = None
) -> str: ) -> str:

View File

@ -4,8 +4,10 @@ menus:
- text: "Save as Template" - text: "Save as Template"
handler: "save_as_template" handler: "save_as_template"
- text: "Manage Templates" - text: "Manage Templates"
handler: "manage_templates" handler: "manage_templates_wrapper"
- separator: true - separator: true
- text: "Manage Queries"
handler: "manage_queries_wrapper"
- separator: true - separator: true
- text: "Exit" - text: "Exit"
handler: "close" handler: "close"

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 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, Filter
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__(
@ -114,10 +128,15 @@ class NoteColours(dbtables.NoteColoursTable):
class Playdates(dbtables.PlaydatesTable): 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""" """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 self.track_id = track_id
session.add(self) session.add(self)
session.commit() session.commit()
@ -207,14 +226,6 @@ class Playlists(dbtables.PlaylistsTable):
self.open = False self.open = False
session.commit() session.commit()
def delete(self, session: Session) -> None:
"""
Delete playlist
"""
session.execute(delete(Playlists).where(Playlists.id == self.id))
session.commit()
@classmethod @classmethod
def get_all(cls, session: Session) -> Sequence["Playlists"]: def get_all(cls, session: Session) -> Sequence["Playlists"]:
"""Returns a list of all playlists ordered by last use""" """Returns a list of all playlists ordered by last use"""
@ -239,10 +250,7 @@ class Playlists(dbtables.PlaylistsTable):
return session.scalars( return session.scalars(
select(cls) select(cls)
.where( .where(cls.is_template.is_(True), cls.favourite.is_(True))
cls.is_template.is_(True),
cls.favourite.is_(True)
)
.order_by(cls.name) .order_by(cls.name)
).all() ).all()
@ -594,6 +602,37 @@ class PlaylistRows(dbtables.PlaylistRowsTable):
session.connection().execute(stmt, sqla_map) session.connection().execute(stmt, sqla_map)
class Queries(dbtables.QueriesTable):
def __init__(
self,
session: Session,
name: str,
filter: dbtables.Filter,
favourite: bool = False,
) -> None:
"""Create new query"""
self.name = name
self.filter = filter
self.favourite = favourite
session.add(self)
session.commit()
@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): class Settings(dbtables.SettingsTable):
def __init__(self, session: Session, name: str) -> None: def __init__(self, session: Session, name: str) -> None:
self.name = name self.name = name
@ -678,6 +717,77 @@ class Tracks(dbtables.TracksTable):
.all() .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 @classmethod
def get_by_path(cls, session: Session, path: str) -> Optional["Tracks"]: def get_by_path(cls, session: Session, path: str) -> Optional["Tracks"]:
""" """

File diff suppressed because it is too large Load Diff

View File

@ -22,10 +22,7 @@ from PyQt6.QtWidgets import (
QFrame, QFrame,
QMenu, QMenu,
QMessageBox, QMessageBox,
QProxyStyle,
QStyle,
QStyledItemDelegate, QStyledItemDelegate,
QStyleOption,
QStyleOptionViewItem, QStyleOptionViewItem,
QTableView, QTableView,
QTableWidgetItem, QTableWidgetItem,
@ -38,7 +35,7 @@ import line_profiler
# App imports # App imports
from audacity_controller import AudacityController 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 config import Config
from dialogs import TrackSelectDialog from dialogs import TrackSelectDialog
from helpers import ( from helpers import (
@ -269,24 +266,6 @@ class PlaylistDelegate(QStyledItemDelegate):
editor.setGeometry(option.rect) 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): class PlaylistTab(QTableView):
""" """
The playlist view The playlist view

288
app/querylistmodel.py Normal file
View File

@ -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"<QuerylistModel: filter={self.filter}, {self.rowCount()} rows>"
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(
"<br>".join(
[
a.lastplayed.strftime(Config.LAST_PLAYED_TOOLTIP_DATE_FORMAT)
for a in reversed(playdates)
]
)
)

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

@ -997,6 +997,9 @@ padding-left: 8px;</string>
<addaction name="actionRenamePlaylist"/> <addaction name="actionRenamePlaylist"/>
<addaction name="actionDeletePlaylist"/> <addaction name="actionDeletePlaylist"/>
<addaction name="separator"/> <addaction name="separator"/>
<addaction name="actionOpenQuerylist"/>
<addaction name="actionManage_querylists"/>
<addaction name="separator"/>
<addaction name="actionSave_as_template"/> <addaction name="actionSave_as_template"/>
<addaction name="actionManage_templates"/> <addaction name="actionManage_templates"/>
<addaction name="separator"/> <addaction name="separator"/>
@ -1369,6 +1372,16 @@ padding-left: 8px;</string>
<string>Import files...</string> <string>Import files...</string>
</property> </property>
</action> </action>
<action name="actionOpenQuerylist">
<property name="text">
<string>Open &amp;querylist...</string>
</property>
</action>
<action name="actionManage_querylists">
<property name="text">
<string>Manage querylists...</string>
</property>
</action>
</widget> </widget>
<customwidgets> <customwidgets>
<customwidget> <customwidget>

View File

@ -1,6 +1,6 @@
# Form implementation generated from reading ui file 'app/ui/main_window.ui' # 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 # WARNING: Any manual changes made to this file will be lost when pyuic6 is
# run again. Do not edit this file unless you know what you are doing. # run again. Do not edit this file unless you know what you are doing.
@ -657,6 +657,10 @@ class Ui_MainWindow(object):
self.actionSelect_duplicate_rows.setObjectName("actionSelect_duplicate_rows") self.actionSelect_duplicate_rows.setObjectName("actionSelect_duplicate_rows")
self.actionImport_files = QtGui.QAction(parent=MainWindow) self.actionImport_files = QtGui.QAction(parent=MainWindow)
self.actionImport_files.setObjectName("actionImport_files") 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.addSeparator()
self.menuFile.addAction(self.actionInsertTrack) self.menuFile.addAction(self.actionInsertTrack)
self.menuFile.addAction(self.actionRemove) self.menuFile.addAction(self.actionRemove)
@ -680,6 +684,9 @@ class Ui_MainWindow(object):
self.menuPlaylist.addAction(self.actionRenamePlaylist) self.menuPlaylist.addAction(self.actionRenamePlaylist)
self.menuPlaylist.addAction(self.actionDeletePlaylist) self.menuPlaylist.addAction(self.actionDeletePlaylist)
self.menuPlaylist.addSeparator() 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.actionSave_as_template)
self.menuPlaylist.addAction(self.actionManage_templates) self.menuPlaylist.addAction(self.actionManage_templates)
self.menuPlaylist.addSeparator() self.menuPlaylist.addSeparator()

View File

@ -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 ###

View File

@ -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 ###

View File

@ -57,8 +57,8 @@ class MyTestCase(unittest.TestCase):
# Create a playlist for all tests # Create a playlist for all tests
playlist_name = "file importer playlist" playlist_name = "file importer playlist"
with db.Session() as session: with db.Session() as session:
playlist = Playlists(session, playlist_name) playlist = Playlists(session=session, name=playlist_name, template_id=0)
cls.widget.create_playlist_tab(playlist) cls.widget._open_playlist(playlist)
# Create our musicstore # Create our musicstore
cls.import_source = tempfile.mkdtemp(suffix="_MMsource_pytest", dir="/tmp") cls.import_source = tempfile.mkdtemp(suffix="_MMsource_pytest", dir="/tmp")

View File

@ -108,7 +108,7 @@ class TestMMModels(unittest.TestCase):
TEMPLATE_NAME = "my template" TEMPLATE_NAME = "my template"
with db.Session() as session: with db.Session() as session:
playlist = Playlists(session, "my playlist") playlist = Playlists(session, "my playlist", template_id=0)
assert playlist assert playlist
# test repr # test repr
_ = str(playlist) _ = str(playlist)
@ -119,23 +119,18 @@ class TestMMModels(unittest.TestCase):
# create template # create template
Playlists.save_as_template(session, playlist.id, TEMPLATE_NAME) 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 # get all templates
all_templates = Playlists.get_all_templates(session) all_templates = Playlists.get_all_templates(session)
assert len(all_templates) == 1 assert len(all_templates) == 1
# Save as template creates new playlist # Save as template creates new playlist
assert all_templates[0] != playlist assert all_templates[0] != playlist
# test delete playlist # test delete playlist
playlist.delete(session) session.delete(playlist)
def test_playlist_open_and_close(self): def test_playlist_open_and_close(self):
# We need a playlist # We need a playlist
with db.Session() as session: 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_open(session)) == 0
assert len(Playlists.get_closed(session)) == 1 assert len(Playlists.get_closed(session)) == 1
@ -155,8 +150,8 @@ class TestMMModels(unittest.TestCase):
p1_name = "playlist one" p1_name = "playlist one"
p2_name = "playlist two" p2_name = "playlist two"
with db.Session() as session: with db.Session() as session:
playlist1 = Playlists(session, p1_name) playlist1 = Playlists(session, p1_name, template_id=0)
_ = Playlists(session, p2_name) _ = Playlists(session, p2_name, template_id=0)
all_playlists = Playlists.get_all(session) all_playlists = Playlists.get_all(session)
assert len(all_playlists) == 2 assert len(all_playlists) == 2
@ -254,7 +249,7 @@ class TestMMModels(unittest.TestCase):
with db.Session() as session: with db.Session() as session:
if Playlists.name_is_available(session, PLAYLIST_NAME): if Playlists.name_is_available(session, PLAYLIST_NAME):
playlist = Playlists(session, PLAYLIST_NAME) playlist = Playlists(session, PLAYLIST_NAME, template_id=0)
assert playlist assert playlist
assert Playlists.name_is_available(session, PLAYLIST_NAME) is False assert Playlists.name_is_available(session, PLAYLIST_NAME) is False
@ -266,7 +261,7 @@ class TestMMModels(unittest.TestCase):
with db.Session() as session: with db.Session() as session:
if Playlists.name_is_available(session, PLAYLIST_NAME): 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) plr = PlaylistRows(session, playlist.id, 1)
assert plr assert plr
@ -279,7 +274,7 @@ class TestMMModels(unittest.TestCase):
with db.Session() as session: with db.Session() as session:
if Playlists.name_is_available(session, PLAYLIST_NAME): 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) plr = PlaylistRows(session, playlist.id, 1)
assert plr assert plr

View File

@ -34,8 +34,8 @@ class TestMMMiscTracks(unittest.TestCase):
# Create a playlist and model # Create a playlist and model
with db.Session() as session: with db.Session() as session:
self.playlist = Playlists(session, PLAYLIST_NAME) self.playlist = Playlists(session, PLAYLIST_NAME, template_id=0)
self.model = playlistmodel.PlaylistModel(self.playlist.id) self.model = playlistmodel.PlaylistModel(self.playlist.id, is_template=False)
for row in range(len(self.test_tracks)): for row in range(len(self.test_tracks)):
track_path = self.test_tracks[row % 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): def test_insert_track_new_playlist(self):
# insert a track into a new playlist # insert a track into a new playlist
with db.Session() as session: with db.Session() as session:
playlist = Playlists(session, self.PLAYLIST_NAME) playlist = Playlists(session, self.PLAYLIST_NAME, template_id=0)
# Create a model # Create a model
model = playlistmodel.PlaylistModel(playlist.id) model = playlistmodel.PlaylistModel(playlist.id, is_template=False)
# test repr # test repr
_ = str(model) _ = str(model)
@ -124,8 +124,8 @@ class TestMMMiscRowMove(unittest.TestCase):
db.create_all() db.create_all()
with db.Session() as session: with db.Session() as session:
self.playlist = Playlists(session, self.PLAYLIST_NAME) self.playlist = Playlists(session, self.PLAYLIST_NAME, template_id=0)
self.model = playlistmodel.PlaylistModel(self.playlist.id) self.model = playlistmodel.PlaylistModel(self.playlist.id, is_template=False)
for row in range(self.ROWS_TO_CREATE): for row in range(self.ROWS_TO_CREATE):
self.model.insert_row(proposed_row_number=row, note=str(row)) self.model.insert_row(proposed_row_number=row, note=str(row))
@ -318,8 +318,8 @@ class TestMMMiscRowMove(unittest.TestCase):
model_src = self.model model_src = self.model
with db.Session() as session: with db.Session() as session:
playlist_dst = Playlists(session, destination_playlist) playlist_dst = Playlists(session, destination_playlist, template_id=0)
model_dst = playlistmodel.PlaylistModel(playlist_dst.id) model_dst = playlistmodel.PlaylistModel(playlist_dst.id, is_template=False)
for row in range(self.ROWS_TO_CREATE): for row in range(self.ROWS_TO_CREATE):
model_dst.insert_row(proposed_row_number=row, note=str(row)) model_dst.insert_row(proposed_row_number=row, note=str(row))
@ -339,8 +339,8 @@ class TestMMMiscRowMove(unittest.TestCase):
model_src = self.model model_src = self.model
with db.Session() as session: with db.Session() as session:
playlist_dst = Playlists(session, destination_playlist) playlist_dst = Playlists(session, destination_playlist, template_id=0)
model_dst = playlistmodel.PlaylistModel(playlist_dst.id) model_dst = playlistmodel.PlaylistModel(playlist_dst.id, is_template=False)
for row in range(self.ROWS_TO_CREATE): for row in range(self.ROWS_TO_CREATE):
model_dst.insert_row(proposed_row_number=row, note=str(row)) model_dst.insert_row(proposed_row_number=row, note=str(row))
@ -366,8 +366,8 @@ class TestMMMiscRowMove(unittest.TestCase):
model_src = self.model model_src = self.model
with db.Session() as session: with db.Session() as session:
playlist_dst = Playlists(session, destination_playlist) playlist_dst = Playlists(session, destination_playlist, template_id=0)
model_dst = playlistmodel.PlaylistModel(playlist_dst.id) model_dst = playlistmodel.PlaylistModel(playlist_dst.id, is_template=False)
for row in range(self.ROWS_TO_CREATE): for row in range(self.ROWS_TO_CREATE):
model_dst.insert_row(proposed_row_number=row, note=str(row)) model_dst.insert_row(proposed_row_number=row, note=str(row))

130
tests/test_queries.py Normal file
View File

@ -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

View File

@ -90,8 +90,8 @@ class MyTestCase(unittest.TestCase):
playlist_name = "test_init playlist" playlist_name = "test_init playlist"
with db.Session() as session: with db.Session() as session:
playlist = Playlists(session, playlist_name) playlist = Playlists(session, playlist_name, template_id=0)
self.widget.create_playlist_tab(playlist) self.widget._open_playlist(playlist, is_template=False)
with self.qtbot.waitExposed(self.widget): with self.qtbot.waitExposed(self.widget):
self.widget.show() self.widget.show()
@ -103,8 +103,8 @@ class MyTestCase(unittest.TestCase):
playlist_name = "test_save_and_restore playlist" playlist_name = "test_save_and_restore playlist"
with db.Session() as session: with db.Session() as session:
playlist = Playlists(session, playlist_name) playlist = Playlists(session, playlist_name, template_id=0)
model = playlistmodel.PlaylistModel(playlist.id) model = playlistmodel.PlaylistModel(playlist.id, is_template=False)
# Add a track with a note # Add a track with a note
model.insert_row( model.insert_row(
@ -139,7 +139,7 @@ class MyTestCase(unittest.TestCase):
# def test_meta_all_clear(qtbot, session): # def test_meta_all_clear(qtbot, session):
# # Create playlist # # 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) # playlist_tab = playlists.PlaylistTab(None, session, playlist.id)
# # Add some tracks # # Add some tracks
@ -167,7 +167,8 @@ class MyTestCase(unittest.TestCase):
# def test_meta(qtbot, session): # def test_meta(qtbot, session):
# # Create playlist # # 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) # playlist_tab = playlists.PlaylistTab(None, session, playlist.id)
# # Add some tracks # # Add some tracks
@ -248,7 +249,7 @@ class MyTestCase(unittest.TestCase):
# def test_clear_next(qtbot, session): # def test_clear_next(qtbot, session):
# # Create playlist # # 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) # playlist_tab = playlists.PlaylistTab(None, session, playlist.id)
# # Add some tracks # # Add some tracks
@ -274,7 +275,7 @@ class MyTestCase(unittest.TestCase):
# # Create playlist and playlist_tab # # Create playlist and playlist_tab
# window = musicmuster.Window() # 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) # playlist_tab = playlists.PlaylistTab(window, session, playlist.id)
# # Add some tracks # # Add some tracks
@ -306,7 +307,7 @@ class MyTestCase(unittest.TestCase):
# playlist_name = "test playlist" # playlist_name = "test playlist"
# # Create testing playlist # # Create testing playlist
# window = musicmuster.Window() # 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) # playlist_tab = playlists.PlaylistTab(window, session, playlist.id)
# idx = window.tabPlaylist.addTab(playlist_tab, playlist_name) # idx = window.tabPlaylist.addTab(playlist_tab, playlist_name)
# window.tabPlaylist.setCurrentIndex(idx) # window.tabPlaylist.setCurrentIndex(idx)