Merge query tabs
This commit is contained in:
commit
f5c77ddffd
@ -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 = ""
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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"<QueriesTable(id={self.id}, name={self.name}, filter={self.filter})>"
|
||||
|
||||
|
||||
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)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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"
|
||||
|
||||
140
app/models.py
140
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"]:
|
||||
"""
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
|
||||
288
app/querylistmodel.py
Normal file
288
app/querylistmodel.py
Normal 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
94
app/ui/dlgQuery.ui
Normal 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 &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
45
app/ui/dlgQuery_ui.py
Normal 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"))
|
||||
@ -997,6 +997,9 @@ padding-left: 8px;</string>
|
||||
<addaction name="actionRenamePlaylist"/>
|
||||
<addaction name="actionDeletePlaylist"/>
|
||||
<addaction name="separator"/>
|
||||
<addaction name="actionOpenQuerylist"/>
|
||||
<addaction name="actionManage_querylists"/>
|
||||
<addaction name="separator"/>
|
||||
<addaction name="actionSave_as_template"/>
|
||||
<addaction name="actionManage_templates"/>
|
||||
<addaction name="separator"/>
|
||||
@ -1369,6 +1372,16 @@ padding-left: 8px;</string>
|
||||
<string>Import files...</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionOpenQuerylist">
|
||||
<property name="text">
|
||||
<string>Open &querylist...</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionManage_querylists">
|
||||
<property name="text">
|
||||
<string>Manage querylists...</string>
|
||||
</property>
|
||||
</action>
|
||||
</widget>
|
||||
<customwidgets>
|
||||
<customwidget>
|
||||
|
||||
@ -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()
|
||||
|
||||
47
migrations/versions/4fc2a9a82ab0_create_queries_table.py
Normal file
47
migrations/versions/4fc2a9a82ab0_create_queries_table.py
Normal 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 ###
|
||||
|
||||
46
migrations/versions/ab475332d873_fix_playdates_cascades.py
Normal file
46
migrations/versions/ab475332d873_fix_playdates_cascades.py
Normal 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 ###
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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))
|
||||
|
||||
|
||||
130
tests/test_queries.py
Normal file
130
tests/test_queries.py
Normal 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
|
||||
@ -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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user