Merge query tabs
This commit is contained in:
commit
f5c77ddffd
@ -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 = ""
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
140
app/models.py
140
app/models.py
@ -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
@ -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
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="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 &querylist...</string>
|
||||||
|
</property>
|
||||||
|
</action>
|
||||||
|
<action name="actionManage_querylists">
|
||||||
|
<property name="text">
|
||||||
|
<string>Manage querylists...</string>
|
||||||
|
</property>
|
||||||
|
</action>
|
||||||
</widget>
|
</widget>
|
||||||
<customwidgets>
|
<customwidgets>
|
||||||
<customwidget>
|
<customwidget>
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
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
|
# 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")
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
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"
|
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)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user