Compare commits

...

49 Commits

Author SHA1 Message Date
Keith Edmunds
be54187b48 Remove old files 2025-03-07 09:46:49 +00:00
Keith Edmunds
6d56a94bca Don't track .venv 2025-03-07 09:43:19 +00:00
Keith Edmunds
ccc1737f2d Issue 285: additional logging and profiling 2025-03-07 09:30:23 +00:00
Keith Edmunds
63b1d0dff4 mypy fixups 2025-03-06 11:33:53 +00:00
Keith Edmunds
2293c663b9 Migrate from poetry to uv 2025-03-05 19:05:13 +00:00
Keith Edmunds
f5c77ddffd Merge query tabs 2025-03-05 15:16:24 +00:00
Keith Edmunds
1cf75a5d42 More query tests and remove Optional from Filter 2025-03-05 14:27:19 +00:00
Keith Edmunds
7fd655f96f WIP: queries working, tests so far good 2025-03-05 09:00:41 +00:00
Keith Edmunds
096889d6cb Fix up tests in light of recent changes 2025-03-04 13:22:29 +00:00
Keith Edmunds
67c48f5022 Select from query working (may need tidying) 2025-03-04 10:32:11 +00:00
Keith Edmunds
8e48d63ebb WIP: queries management
Menus and management working. Wrong tracks showing up in queries.
2025-03-02 19:14:53 +00:00
Keith Edmunds
aa6ab03555 Make manage queries and manage templates into classes 2025-02-28 11:25:29 +00:00
Keith Edmunds
fc02a4aa7e Merge branch 'bug283' into dev 2025-02-28 09:21:47 +00:00
Keith Edmunds
6223ef0ef0 Don't allow deletion of current or next track
Fixes: #283
2025-02-28 09:21:22 +00:00
Keith Edmunds
76e6084419 Try to speed up tab switching 2025-02-27 18:21:55 +00:00
Keith Edmunds
90d72464cb Clean up handling of separators in dynamic menu 2025-02-27 08:13:29 +00:00
Keith Edmunds
82e707a6f6 Make filter field in queries table non-nullable 2025-02-27 08:12:48 +00:00
Keith Edmunds
b4f5d92f5d WIP: query management 2025-02-26 13:58:13 +00:00
Keith Edmunds
985629446a Create queries table 2025-02-26 13:34:10 +00:00
Keith Edmunds
64ccb485b5 Fix playdates cascade deletes 2025-02-26 13:29:42 +00:00
Keith Edmunds
3f248d363f rebase from dev 2025-02-23 21:06:42 +00:00
Keith Edmunds
40756469ec WIP query tabs 2025-02-23 21:06:42 +00:00
Keith Edmunds
306ab103b6 Add favourite to queries table 2025-02-23 21:06:42 +00:00
Keith Edmunds
994d510ed9 Move querylistmodel from SQL to filter 2025-02-23 21:06:42 +00:00
Keith Edmunds
8b8edba64d Add Filter class to classes 2025-02-23 21:06:42 +00:00
Keith Edmunds
678515403c Guard against erroneous SQL statements in queries 2025-02-23 21:06:42 +00:00
Keith Edmunds
e6404d075e Query searches working
More UI needed
2025-02-23 21:06:42 +00:00
Keith Edmunds
7c0db00b75 Create databases in dbmanager 2025-02-23 21:06:42 +00:00
Keith Edmunds
e4e061cf1c Add open querylist menu 2025-02-23 21:06:42 +00:00
Keith Edmunds
61021b33b8 Fix hide played button 2025-02-23 21:06:42 +00:00
Keith Edmunds
a33589a9a1 "=" header fixes
Fixes: #276
2025-02-23 21:06:42 +00:00
Keith Edmunds
3547046cc1 Misc cleanups from query_tabs branch 2025-02-23 21:06:41 +00:00
Keith Edmunds
95983c73b1 Log to stderr timer10 stop/start 2025-02-23 21:06:41 +00:00
Keith Edmunds
499c0c6b70 Fix "=" header
Fixes: #276
2025-02-23 21:06:41 +00:00
Keith Edmunds
33e2c4bf31 Fix order of playdates on hover
Fixes: #275
2025-02-23 21:06:41 +00:00
Keith Edmunds
589a664971 New template from manage templates correctly marked in db 2025-02-23 17:34:23 +00:00
Keith Edmunds
67bf926ed8 Refactor musicmuster and template management 2025-02-23 17:28:03 +00:00
Keith Edmunds
040020e7ed Refactor playlist management functions 2025-02-23 17:26:43 +00:00
Keith Edmunds
911859ef49 Show red start in tab of templates 2025-02-23 17:24:47 +00:00
Keith Edmunds
68bdff53cf Move menu.yaml into app/ 2025-02-23 09:20:30 +00:00
Keith Edmunds
632937101a WIP dynamic menu for playlist
New playlist shows faves on submenu
2025-02-22 22:27:05 +00:00
Keith Edmunds
639f006a10 Add favourite to playlists 2025-02-22 20:23:07 +00:00
Keith Edmunds
9e27418f80 Remove queries table definition
It mistakenly was introduced to the wrong branch. It persists on the
query_tabs branch.
2025-02-22 20:13:44 +00:00
Keith Edmunds
c1448dfdd5 WIP: manage templates: template rows have different background 2025-02-22 19:42:48 +00:00
Keith Edmunds
5f396a0993 WIP: template management: new, rename, delete working 2025-02-22 19:16:42 +00:00
Keith Edmunds
e10c2adafe WIP: template management: edit and delete working 2025-02-22 11:34:36 +00:00
Keith Edmunds
b0f6e4e819 Framework for dynamic submenus 2025-02-21 15:18:45 +00:00
Keith Edmunds
afd3be608c Move menu definitions to YAML file 2025-02-21 14:16:34 +00:00
Keith Edmunds
955bea2037 Query tabs WIP 2025-02-11 21:11:56 +00:00
39 changed files with 5006 additions and 2879 deletions

2
.envrc
View File

@ -1,4 +1,4 @@
layout poetry layout uv
export LINE_PROFILE=1 export LINE_PROFILE=1
export MAIL_PASSWORD="ewacyay5seu2qske" export MAIL_PASSWORD="ewacyay5seu2qske"
export MAIL_PORT=587 export MAIL_PORT=587

1
.gitignore vendored
View File

@ -2,6 +2,7 @@
*.pyc *.pyc
*.swp *.swp
tags tags
.venv/
venv/ venv/
Session.vim Session.vim
*.flac *.flac

View File

@ -1 +1 @@
musicmuster 3.13

View File

@ -1,78 +0,0 @@
#!/usr/bin/env python3
from PyQt6.QtCore import Qt, QEvent, QObject
from PyQt6.QtWidgets import (
QAbstractItemView,
QApplication,
QMainWindow,
QMessageBox,
QPlainTextEdit,
QStyledItemDelegate,
QTableWidget,
QTableWidgetItem,
)
from PyQt6.QtGui import QKeyEvent
from typing import cast
class EscapeDelegate(QStyledItemDelegate):
def __init__(self, parent=None):
super().__init__(parent)
def createEditor(self, parent, option, index):
return QPlainTextEdit(parent)
def eventFilter(self, editor: QObject, event: QEvent):
"""By default, QPlainTextEdit doesn't handle enter or return"""
print("EscapeDelegate event handler")
if event.type() == QEvent.Type.KeyPress:
key_event = cast(QKeyEvent, event)
if key_event.key() == Qt.Key.Key_Return:
if key_event.modifiers() == (Qt.KeyboardModifier.ControlModifier):
print("save data")
self.commitData.emit(editor)
self.closeEditor.emit(editor)
return True
elif key_event.key() == Qt.Key.Key_Escape:
discard_edits = QMessageBox.question(
self.parent(), "Abandon edit", "Discard changes?"
)
if discard_edits == QMessageBox.StandardButton.Yes:
print("abandon edit")
self.closeEditor.emit(editor)
return True
return False
class MyTableWidget(QTableWidget):
def __init__(self, parent=None):
super().__init__(parent)
self.setItemDelegate(EscapeDelegate(self))
# self.setEditTriggers(QAbstractItemView.EditTrigger.DoubleClicked)
class MainWindow(QMainWindow):
def __init__(self, parent=None):
super().__init__(parent)
self.table_widget = MyTableWidget(self)
self.table_widget.setRowCount(2)
self.table_widget.setColumnCount(2)
for row in range(2):
for col in range(2):
item = QTableWidgetItem()
item.setText(f"Row {row}, Col {col}")
self.table_widget.setItem(row, col, item)
self.setCentralWidget(self.table_widget)
self.table_widget.resizeColumnsToContents()
self.table_widget.resizeRowsToContents()
if __name__ == "__main__":
app = QApplication([])
window = MainWindow()
window.show()
app.exec()

View File

@ -1,94 +0,0 @@
#!/usr/bin/env python3
from PyQt6.QtCore import Qt, QEvent, QObject, QVariant, QAbstractTableModel
from PyQt6.QtWidgets import (
QApplication,
QMainWindow,
QMessageBox,
QPlainTextEdit,
QStyledItemDelegate,
QTableView,
)
from PyQt6.QtGui import QKeyEvent
from typing import cast
class EscapeDelegate(QStyledItemDelegate):
def __init__(self, parent=None):
super().__init__(parent)
def createEditor(self, parent, option, index):
return QPlainTextEdit(parent)
def eventFilter(self, editor: QObject, event: QEvent):
"""By default, QPlainTextEdit doesn't handle enter or return"""
if event.type() == QEvent.Type.KeyPress:
key_event = cast(QKeyEvent, event)
print(key_event.key())
if key_event.key() == Qt.Key.Key_Return:
if key_event.modifiers() == (Qt.KeyboardModifier.ControlModifier):
print("save data")
self.commitData.emit(editor)
self.closeEditor.emit(editor)
return True
elif key_event.key() == Qt.Key.Key_Escape:
discard_edits = QMessageBox.question(
self.parent(), "Abandon edit", "Discard changes?"
)
if discard_edits == QMessageBox.StandardButton.Yes:
print("abandon edit")
self.closeEditor.emit(editor)
return True
return False
class MyTableWidget(QTableView):
def __init__(self, parent=None):
super().__init__(parent)
self.setItemDelegate(EscapeDelegate(self))
self.setModel(MyModel())
class MyModel(QAbstractTableModel):
def columnCount(self, index):
return 2
def rowCount(self, index):
return 2
def data(self, index, role):
if not index.isValid() or not (0 <= index.row() < 2):
return QVariant()
row = index.row()
column = index.column()
if role == Qt.ItemDataRole.DisplayRole:
return QVariant(f"Row {row}, Col {column}")
return QVariant()
def flags(self, index):
return (
Qt.ItemFlag.ItemIsEnabled
| Qt.ItemFlag.ItemIsSelectable
| Qt.ItemFlag.ItemIsEditable
)
class MainWindow(QMainWindow):
def __init__(self, parent=None):
super().__init__(parent)
self.table_widget = MyTableWidget(self)
self.setCentralWidget(self.table_widget)
self.table_widget.resizeColumnsToContents()
self.table_widget.resizeRowsToContents()
if __name__ == "__main__":
app = QApplication([])
window = MainWindow()
window.show()
app.exec()

View File

@ -5,7 +5,7 @@ from dataclasses import dataclass
from enum import auto, Enum from enum import auto, Enum
import functools import functools
import threading import threading
from typing import NamedTuple from typing import NamedTuple, Optional
# Third party imports # Third party imports
@ -14,23 +14,16 @@ from PyQt6.QtCore import (
pyqtSignal, pyqtSignal,
QObject, QObject,
) )
from PyQt6.QtWidgets import (
QProxyStyle,
QStyle,
QStyleOption,
)
# App imports # App imports
class Col(Enum): # Define singleton first as it's needed below
START_GAP = 0
TITLE = auto()
ARTIST = auto()
INTRO = auto()
DURATION = auto()
START_TIME = auto()
END_TIME = auto()
LAST_PLAYED = auto()
BITRATE = auto()
NOTE = auto()
def singleton(cls): def singleton(cls):
""" """
Make a class a Singleton class (see Make a class a Singleton class (see
@ -53,11 +46,6 @@ def singleton(cls):
return wrapper_singleton return wrapper_singleton
class FileErrors(NamedTuple):
path: str
error: str
class ApplicationError(Exception): class ApplicationError(Exception):
""" """
Custom exception Custom exception
@ -72,6 +60,37 @@ class AudioMetadata(NamedTuple):
fade_at: int = 0 fade_at: int = 0
class Col(Enum):
START_GAP = 0
TITLE = auto()
ARTIST = auto()
INTRO = auto()
DURATION = auto()
START_TIME = auto()
END_TIME = auto()
LAST_PLAYED = auto()
BITRATE = auto()
NOTE = auto()
class FileErrors(NamedTuple):
path: str
error: str
@dataclass
class Filter:
version: int = 1
path_type: str = "contains"
path: str = ""
last_played_number: int = 0
last_played_comparator: str = "before"
last_played_unit: str = "years"
duration_type: str = "longer than"
duration_number: int = 0
duration_unit: str = "minutes"
@singleton @singleton
@dataclass @dataclass
class MusicMusterSignals(QObject): class MusicMusterSignals(QObject):
@ -100,6 +119,32 @@ class MusicMusterSignals(QObject):
super().__init__() super().__init__()
class PlaylistStyle(QProxyStyle):
def drawPrimitive(self, element, option, painter, widget=None):
"""
Draw a line across the entire row rather than just the column
we're hovering over.
"""
if (
element == QStyle.PrimitiveElement.PE_IndicatorItemViewItemDrop
and not option.rect.isNull()
):
option_new = QStyleOption(option)
option_new.rect.setLeft(0)
if widget:
option_new.rect.setRight(widget.width())
option = option_new
super().drawPrimitive(element, option, painter, widget)
class QueryCol(Enum):
TITLE = 0
ARTIST = auto()
DURATION = auto()
LAST_PLAYED = auto()
BITRATE = auto()
class Tags(NamedTuple): class Tags(NamedTuple):
artist: str = "" artist: str = ""
title: str = "" title: str = ""

View File

@ -31,6 +31,7 @@ class Config(object):
COLOUR_NORMAL_TAB = "#000000" COLOUR_NORMAL_TAB = "#000000"
COLOUR_NOTES_PLAYLIST = "#b8daff" COLOUR_NOTES_PLAYLIST = "#b8daff"
COLOUR_ODD_PLAYLIST = "#f2f2f2" COLOUR_ODD_PLAYLIST = "#f2f2f2"
COLOUR_QUERYLIST_SELECTED = "#d3ffd3"
COLOUR_UNREADABLE = "#dc3545" COLOUR_UNREADABLE = "#dc3545"
COLOUR_WARNING_TIMER = "#ffc107" COLOUR_WARNING_TIMER = "#ffc107"
DBFS_SILENCE = -50 DBFS_SILENCE = -50
@ -38,6 +39,7 @@ class Config(object):
DISPLAY_SQL = False DISPLAY_SQL = False
DO_NOT_IMPORT = "Do not import" DO_NOT_IMPORT = "Do not import"
ENGINE_OPTIONS = dict(pool_pre_ping=True) ENGINE_OPTIONS = dict(pool_pre_ping=True)
# ENGINE_OPTIONS = dict(pool_pre_ping=True, echo=True)
EPOCH = dt.datetime(1970, 1, 1) EPOCH = dt.datetime(1970, 1, 1)
ERRORS_FROM = ["noreply@midnighthax.com"] ERRORS_FROM = ["noreply@midnighthax.com"]
ERRORS_TO = ["kae@midnighthax.com"] ERRORS_TO = ["kae@midnighthax.com"]
@ -48,6 +50,19 @@ class Config(object):
FADEOUT_DB = -10 FADEOUT_DB = -10
FADEOUT_SECONDS = 5 FADEOUT_SECONDS = 5
FADEOUT_STEPS_PER_SECOND = 5 FADEOUT_STEPS_PER_SECOND = 5
FILTER_DURATION_LONGER = "longer than"
FILTER_DURATION_MINUTES = "minutes"
FILTER_DURATION_SECONDS = "seconds"
FILTER_DURATION_SHORTER = "shorter than"
FILTER_PATH_CONTAINS = "contains"
FILTER_PATH_EXCLUDING = "excluding"
FILTER_PLAYED_COMPARATOR_ANYTIME = "Any time"
FILTER_PLAYED_COMPARATOR_BEFORE = "before"
FILTER_PLAYED_COMPARATOR_NEVER = "never"
FILTER_PLAYED_DAYS = "days"
FILTER_PLAYED_MONTHS = "months"
FILTER_PLAYED_WEEKS = "weeks"
FILTER_PLAYED_YEARS = "years"
FUZZYMATCH_MINIMUM_LIST = 60.0 FUZZYMATCH_MINIMUM_LIST = 60.0
FUZZYMATCH_MINIMUM_SELECT_ARTIST = 80.0 FUZZYMATCH_MINIMUM_SELECT_ARTIST = 80.0
FUZZYMATCH_MINIMUM_SELECT_TITLE = 80.0 FUZZYMATCH_MINIMUM_SELECT_TITLE = 80.0
@ -86,6 +101,7 @@ class Config(object):
MAX_MISSING_FILES_TO_REPORT = 10 MAX_MISSING_FILES_TO_REPORT = 10
MILLISECOND_SIGFIGS = 0 MILLISECOND_SIGFIGS = 0
MINIMUM_ROW_HEIGHT = 30 MINIMUM_ROW_HEIGHT = 30
NO_QUERY_NAME = "Select query"
NO_TEMPLATE_NAME = "None" NO_TEMPLATE_NAME = "None"
NOTE_TIME_FORMAT = "%H:%M" NOTE_TIME_FORMAT = "%H:%M"
OBS_HOST = "localhost" OBS_HOST = "localhost"
@ -95,6 +111,7 @@ class Config(object):
PLAY_SETTLE = 500000 PLAY_SETTLE = 500000
PLAYLIST_ICON_CURRENT = ":/icons/green-circle.png" PLAYLIST_ICON_CURRENT = ":/icons/green-circle.png"
PLAYLIST_ICON_NEXT = ":/icons/yellow-circle.png" PLAYLIST_ICON_NEXT = ":/icons/yellow-circle.png"
PLAYLIST_ICON_TEMPLATE = ":/icons/redstar.png"
PREVIEW_ADVANCE_MS = 5000 PREVIEW_ADVANCE_MS = 5000
PREVIEW_BACK_MS = 5000 PREVIEW_BACK_MS = 5000
PREVIEW_END_BUFFER_MS = 1000 PREVIEW_END_BUFFER_MS = 1000

View File

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

View File

@ -1,6 +1,8 @@
# Standard library imports # Standard library imports
from typing import Optional from typing import Optional
from dataclasses import asdict
import datetime as dt import datetime as dt
import json
# PyQt imports # PyQt imports
@ -13,13 +15,37 @@ from sqlalchemy import (
String, String,
) )
from sqlalchemy.ext.associationproxy import association_proxy from sqlalchemy.ext.associationproxy import association_proxy
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.engine.interfaces import Dialect
from sqlalchemy.orm import ( from sqlalchemy.orm import (
Mapped, Mapped,
mapped_column, mapped_column,
relationship, relationship,
) )
from sqlalchemy.types import TypeDecorator, TEXT
# App imports # App imports
from classes import Filter
class JSONEncodedDict(TypeDecorator):
"""
Custom JSON Type for MariaDB (since native JSON type is just LONGTEXT)
"""
impl = TEXT
def process_bind_param(self, value: dict | None, dialect: Dialect) -> str | None:
"""Convert Python dictionary to JSON string before saving."""
if value is None:
return None
return json.dumps(value, default=lambda o: o.__dict__)
def process_result_value(self, value: str | None, dialect: Dialect) -> dict | None:
"""Convert JSON string back to Python dictionary after retrieval."""
if value is None:
return None
return json.loads(value)
# Database classes # Database classes
@ -48,7 +74,7 @@ class PlaydatesTable(Model):
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
lastplayed: Mapped[dt.datetime] = mapped_column(index=True) lastplayed: Mapped[dt.datetime] = mapped_column(index=True)
track_id: Mapped[int] = mapped_column(ForeignKey("tracks.id")) track_id: Mapped[int] = mapped_column(ForeignKey("tracks.id", ondelete="CASCADE"))
track: Mapped["TracksTable"] = relationship( track: Mapped["TracksTable"] = relationship(
"TracksTable", "TracksTable",
back_populates="playdates", back_populates="playdates",
@ -80,8 +106,8 @@ class PlaylistsTable(Model):
cascade="all, delete-orphan", cascade="all, delete-orphan",
order_by="PlaylistRowsTable.row_number", order_by="PlaylistRowsTable.row_number",
) )
query: Mapped["QueriesTable"] = relationship( favourite: Mapped[bool] = mapped_column(
back_populates="playlist", cascade="all, delete-orphan" Boolean, nullable=False, index=False, default=False
) )
def __repr__(self) -> str: def __repr__(self) -> str:
@ -104,7 +130,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: Mapped["TracksTable"] = relationship( track: Mapped["TracksTable"] = relationship(
"TracksTable", "TracksTable",
back_populates="playlistrows", back_populates="playlistrows",
@ -125,14 +153,25 @@ class QueriesTable(Model):
__tablename__ = "queries" __tablename__ = "queries"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
query: Mapped[str] = mapped_column( name: Mapped[str] = mapped_column(String(128), nullable=False)
String(2048), index=False, default="", 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)
playlist_id: Mapped[int] = mapped_column(ForeignKey("playlists.id"), index=True)
playlist: Mapped[PlaylistsTable] = relationship(back_populates="query") 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: def __repr__(self) -> str:
return f"<Queries(id={self.id}, playlist={self.playlist}, query={self.query}>" return f"<QueriesTable(id={self.id}, name={self.name}, filter={self.filter})>"
class SettingsTable(Model): class SettingsTable(Model):
@ -158,7 +197,7 @@ class TracksTable(Model):
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
artist: Mapped[str] = mapped_column(String(256), index=True) artist: Mapped[str] = mapped_column(String(256), index=True)
bitrate: Mapped[Optional[int]] = mapped_column(default=None) bitrate: Mapped[int] = mapped_column(default=None)
duration: Mapped[int] = mapped_column(index=True) duration: Mapped[int] = mapped_column(index=True)
fade_at: Mapped[int] = mapped_column(index=False) fade_at: Mapped[int] = mapped_column(index=False)
intro: Mapped[Optional[int]] = mapped_column(default=None) intro: Mapped[Optional[int]] = mapped_column(default=None)

View File

@ -10,7 +10,7 @@ import ssl
import tempfile import tempfile
# PyQt imports # PyQt imports
from PyQt6.QtWidgets import QMainWindow, QMessageBox, QWidget from PyQt6.QtWidgets import QInputDialog, QMainWindow, QMessageBox, QWidget
# Third party imports # Third party imports
from mutagen.flac import FLAC # type: ignore from mutagen.flac import FLAC # type: ignore
@ -150,6 +150,23 @@ def get_audio_metadata(filepath: str) -> AudioMetadata:
) )
def get_name(prompt: str, default: str = "") -> str | None:
"""Get a name from the user"""
dlg = QInputDialog()
dlg.setInputMode(QInputDialog.InputMode.TextInput)
dlg.setLabelText(prompt)
while True:
if default:
dlg.setTextValue(default)
dlg.resize(500, 100)
ok = dlg.exec()
if ok:
return dlg.textValue()
return None
def get_relative_date( def get_relative_date(
past_date: Optional[dt.datetime], reference_date: Optional[dt.datetime] = None past_date: Optional[dt.datetime], reference_date: Optional[dt.datetime] = None
) -> str: ) -> str:

104
app/menu.yaml Normal file
View File

@ -0,0 +1,104 @@
menus:
- title: "&File"
actions:
- text: "Save as Template"
handler: "save_as_template"
- text: "Manage Templates"
handler: "manage_templates_wrapper"
- separator: true
- text: "Manage Queries"
handler: "manage_queries_wrapper"
- separator: true
- text: "Exit"
handler: "close"
- title: "&Playlist"
actions:
- text: "Open Playlist"
handler: "open_existing_playlist"
shortcut: "Ctrl+O"
- text: "New Playlist"
handler: "new_playlist_dynamic_submenu"
submenu: true
- text: "Close Playlist"
handler: "close_playlist_tab"
- text: "Rename Playlist"
handler: "rename_playlist"
- text: "Delete Playlist"
handler: "delete_playlist"
- separator: true
- text: "Insert Track"
handler: "insert_track"
shortcut: "Ctrl+T"
- text: "Select Track from Query"
handler: "query_dynamic_submenu"
submenu: true
- text: "Insert Section Header"
handler: "insert_header"
shortcut: "Ctrl+H"
- text: "Import Files"
handler: "import_files_wrapper"
shortcut: "Ctrl+Shift+I"
- separator: true
- text: "Mark for Moving"
handler: "mark_rows_for_moving"
shortcut: "Ctrl+C"
- text: "Paste"
handler: "paste_rows"
shortcut: "Ctrl+V"
- separator: true
- text: "Export Playlist"
handler: "export_playlist_tab"
- text: "Download CSV of Played Tracks"
handler: "download_played_tracks"
- separator: true
- text: "Select Duplicate Rows"
handler: "select_duplicate_rows"
- text: "Move Selected"
handler: "move_selected"
- text: "Move Unplayed"
handler: "move_unplayed"
- separator: true
- text: "Clear Selection"
handler: "clear_selection"
shortcut: "Esc"
store_reference: true # So we can enable/disable later
- title: "&Music"
actions:
- text: "Set Next"
handler: "set_selected_track_next"
shortcut: "Ctrl+N"
- text: "Play Next"
handler: "play_next"
shortcut: "Return"
- text: "Fade"
handler: "fade"
shortcut: "Ctrl+Z"
- text: "Stop"
handler: "stop"
shortcut: "Ctrl+Alt+S"
- text: "Resume"
handler: "resume"
shortcut: "Ctrl+R"
- text: "Skip to Next"
handler: "play_next"
shortcut: "Ctrl+Alt+Return"
- separator: true
- text: "Search"
handler: "search_playlist"
shortcut: "/"
- text: "Search Title in Wikipedia"
handler: "lookup_row_in_wikipedia"
shortcut: "Ctrl+W"
- text: "Search Title in Songfacts"
handler: "lookup_row_in_songfacts"
shortcut: "Ctrl+S"
- title: "Help"
actions:
- text: "About"
handler: "about"
- text: "Debug"
handler: "debug"

View File

@ -15,14 +15,17 @@ from sqlalchemy import (
delete, delete,
func, func,
select, select,
text,
update, update,
) )
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError, ProgrammingError
from sqlalchemy.orm.exc import NoResultFound from sqlalchemy.orm.exc import NoResultFound
from sqlalchemy.orm import joinedload 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()
@ -179,12 +198,18 @@ class Playdates(dbtables.PlaydatesTable):
class Playlists(dbtables.PlaylistsTable): class Playlists(dbtables.PlaylistsTable):
def __init__(self, session: Session, name: str): def __init__(self, session: Session, name: str, template_id: int) -> None:
"""Create playlist with passed name"""
self.name = name self.name = name
self.last_used = dt.datetime.now() self.last_used = dt.datetime.now()
session.add(self) session.add(self)
session.commit() session.commit()
# If a template is specified, copy from it
if template_id:
PlaylistRows.copy_playlist(session, template_id, self.id)
@staticmethod @staticmethod
def clear_tabs(session: Session, playlist_ids: list[int]) -> None: def clear_tabs(session: Session, playlist_ids: list[int]) -> None:
""" """
@ -201,34 +226,6 @@ class Playlists(dbtables.PlaylistsTable):
self.open = False self.open = False
session.commit() session.commit()
@classmethod
def create_playlist_from_template(
cls, session: Session, template: "Playlists", playlist_name: str
) -> Optional["Playlists"]:
"""Create a new playlist from template"""
# Sanity check
if not template.id:
return None
playlist = cls(session, playlist_name)
# Sanity / mypy checks
if not playlist or not playlist.id:
return None
PlaylistRows.copy_playlist(session, template.id, playlist.id)
return playlist
def delete(self, session: Session) -> None:
"""
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"""
@ -247,6 +244,16 @@ class Playlists(dbtables.PlaylistsTable):
select(cls).where(cls.is_template.is_(True)).order_by(cls.name) select(cls).where(cls.is_template.is_(True)).order_by(cls.name)
).all() ).all()
@classmethod
def get_favourite_templates(cls, session: Session) -> Sequence["Playlists"]:
"""Returns a list of favourite templates ordered by name"""
return session.scalars(
select(cls)
.where(cls.is_template.is_(True), cls.favourite.is_(True))
.order_by(cls.name)
).all()
@classmethod @classmethod
def get_closed(cls, session: Session) -> Sequence["Playlists"]: def get_closed(cls, session: Session) -> Sequence["Playlists"]:
"""Returns a list of all closed playlists ordered by last use""" """Returns a list of all closed playlists ordered by last use"""
@ -301,7 +308,7 @@ class Playlists(dbtables.PlaylistsTable):
) -> None: ) -> None:
"""Save passed playlist as new template""" """Save passed playlist as new template"""
template = Playlists(session, template_name) template = Playlists(session, template_name, template_id=0)
if not template or not template.id: if not template or not template.id:
return return
@ -595,8 +602,39 @@ 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): def __init__(self, session: Session, name: str) -> None:
self.name = name self.name = name
session.add(self) session.add(self)
session.commit() session.commit()
@ -624,7 +662,7 @@ class Tracks(dbtables.TracksTable):
fade_at: int, fade_at: int,
silence_at: int, silence_at: int,
bitrate: int, bitrate: int,
): ) -> None:
self.path = path self.path = path
self.title = title self.title = title
self.artist = artist self.artist = artist
@ -679,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"]:
""" """

View File

@ -458,7 +458,7 @@ class RowAndTrack:
self.title = playlist_row.track.title self.title = playlist_row.track.title
else: else:
self.artist = "" self.artist = ""
self.bitrate = None self.bitrate = 0
self.duration = 0 self.duration = 0
self.fade_at = 0 self.fade_at = 0
self.intro = None self.intro = None

File diff suppressed because it is too large Load Diff

View File

@ -26,6 +26,8 @@ from PyQt6.QtGui import (
) )
# Third party imports # Third party imports
import line_profiler
from sqlalchemy.orm.session import Session
import obswebsocket # type: ignore import obswebsocket # type: ignore
# import snoop # type: ignore # import snoop # type: ignore
@ -74,12 +76,14 @@ class PlaylistModel(QAbstractTableModel):
def __init__( def __init__(
self, self,
playlist_id: int, playlist_id: int,
is_template: bool,
*args: Optional[QObject], *args: Optional[QObject],
**kwargs: Optional[QObject], **kwargs: Optional[QObject],
) -> None: ) -> None:
log.debug("PlaylistModel.__init__()") log.debug("PlaylistModel.__init__()")
self.playlist_id = playlist_id self.playlist_id = playlist_id
self.is_template = is_template
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.playlist_rows: dict[int, RowAndTrack] = {} self.playlist_rows: dict[int, RowAndTrack] = {}
@ -726,21 +730,27 @@ class PlaylistModel(QAbstractTableModel):
self.reset_track_sequence_row_numbers() self.reset_track_sequence_row_numbers()
self.invalidate_rows(list(range(new_row_number, len(self.playlist_rows)))) self.invalidate_rows(list(range(new_row_number, len(self.playlist_rows))))
@line_profiler.profile
def invalidate_row(self, modified_row: int) -> None: def invalidate_row(self, modified_row: int) -> None:
""" """
Signal to view to refresh invalidated row Signal to view to refresh invalidated row
""" """
log.debug(f"issue285: {self}: invalidate_row({modified_row=})")
self.dataChanged.emit( self.dataChanged.emit(
self.index(modified_row, 0), self.index(modified_row, 0),
self.index(modified_row, self.columnCount() - 1), self.index(modified_row, self.columnCount() - 1),
) )
@line_profiler.profile
def invalidate_rows(self, modified_rows: list[int]) -> None: def invalidate_rows(self, modified_rows: list[int]) -> None:
""" """
Signal to view to refresh invlidated rows Signal to view to refresh invlidated rows
""" """
log.debug(f"issue285: {self}: invalidate_rows({modified_rows=})")
for modified_row in modified_rows: for modified_row in modified_rows:
self.invalidate_row(modified_row) self.invalidate_row(modified_row)
@ -772,7 +782,7 @@ class PlaylistModel(QAbstractTableModel):
return None return None
def load_data(self, session: db.session) -> None: def load_data(self, session: Session) -> None:
""" """
Same as refresh data, but only used when creating playslit. Same as refresh data, but only used when creating playslit.
Distinguishes profile time between initial load and other Distinguishes profile time between initial load and other
@ -1061,7 +1071,7 @@ class PlaylistModel(QAbstractTableModel):
# Update display # Update display
self.invalidate_row(track_sequence.previous.row_number) self.invalidate_row(track_sequence.previous.row_number)
def refresh_data(self, session: db.session) -> None: def refresh_data(self, session: Session) -> None:
""" """
Populate self.playlist_rows with playlist data Populate self.playlist_rows with playlist data
@ -1130,6 +1140,7 @@ class PlaylistModel(QAbstractTableModel):
self.signals.resize_rows_signal.emit(self.playlist_id) self.signals.resize_rows_signal.emit(self.playlist_id)
session.commit() session.commit()
@line_profiler.profile
def reset_track_sequence_row_numbers(self) -> None: def reset_track_sequence_row_numbers(self) -> None:
""" """
Signal handler for when row ordering has changed. Signal handler for when row ordering has changed.
@ -1140,7 +1151,7 @@ class PlaylistModel(QAbstractTableModel):
looking up the playlistrow_id and retrieving the row number from the database. looking up the playlistrow_id and retrieving the row number from the database.
""" """
log.debug(f"{self}: reset_track_sequence_row_numbers()") log.debug(f"issue285: {self}: reset_track_sequence_row_numbers()")
# Check the track_sequence.next, current and previous plrs and # Check the track_sequence.next, current and previous plrs and
# update the row number # update the row number
@ -1264,6 +1275,7 @@ class PlaylistModel(QAbstractTableModel):
return header_text return header_text
@line_profiler.profile
def rowCount(self, index: QModelIndex = QModelIndex()) -> int: def rowCount(self, index: QModelIndex = QModelIndex()) -> int:
"""Standard function for view""" """Standard function for view"""
@ -1585,12 +1597,13 @@ class PlaylistModel(QAbstractTableModel):
else: else:
self.insert_row(proposed_row_number=row_number, track_id=track_id) self.insert_row(proposed_row_number=row_number, track_id=track_id)
@line_profiler.profile
def update_track_times(self) -> None: def update_track_times(self) -> None:
""" """
Update track start/end times in self.playlist_rows Update track start/end times in self.playlist_rows
""" """
log.debug(f"{self}: update_track_times()") log.debug(f"issue285: {self}: update_track_times()")
next_start_time: Optional[dt.datetime] = None next_start_time: Optional[dt.datetime] = None
update_rows: list[int] = [] update_rows: list[int] = []

View File

@ -22,10 +22,7 @@ from PyQt6.QtWidgets import (
QFrame, QFrame,
QMenu, QMenu,
QMessageBox, QMessageBox,
QProxyStyle,
QStyle,
QStyledItemDelegate, QStyledItemDelegate,
QStyleOption,
QStyleOptionViewItem, QStyleOptionViewItem,
QTableView, QTableView,
QTableWidgetItem, QTableWidgetItem,
@ -37,7 +34,7 @@ from PyQt6.QtWidgets import (
# 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 (
@ -268,24 +265,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
@ -364,7 +343,7 @@ class PlaylistTab(QTableView):
Override closeEditor to enable play controls and update display. Override closeEditor to enable play controls and update display.
""" """
self.musicmuster.action_Clear_selection.setEnabled(True) self.musicmuster.enable_escape(True)
super(PlaylistTab, self).closeEditor(editor, hint) super(PlaylistTab, self).closeEditor(editor, hint)
@ -774,14 +753,29 @@ class PlaylistTab(QTableView):
if row_count < 1: if row_count < 1:
return return
# Don't delete current or next tracks
selected_row_numbers = self.selected_model_row_numbers()
for ts in [
track_sequence.next,
track_sequence.current,
]:
if ts:
if (
ts.playlist_id == self.playlist_id
and ts.row_number in selected_row_numbers
):
self.musicmuster.show_warning(
"Delete not allowed", "Can't delete current or next track"
)
return
# Get confirmation # Get confirmation
plural = "s" if row_count > 1 else "" plural = "s" if row_count > 1 else ""
if not ask_yes_no("Delete rows", f"Really delete {row_count} row{plural}?"): if not ask_yes_no("Delete rows", f"Really delete {row_count} row{plural}?"):
return return
base_model = self.get_base_model() base_model = self.get_base_model()
base_model.delete_rows(selected_row_numbers)
base_model.delete_rows(self.selected_model_row_numbers())
self.clear_selection() self.clear_selection()
def get_base_model(self) -> PlaylistModel: def get_base_model(self) -> PlaylistModel:
@ -830,14 +824,12 @@ class PlaylistTab(QTableView):
# Use a set to deduplicate result (a selected row will have all # Use a set to deduplicate result (a selected row will have all
# items in that row selected) # items in that row selected)
result = sorted( selected_indexes = self.selectedIndexes()
list(
set([self.model().mapToSource(a).row() for a in self.selectedIndexes()])
)
)
log.debug(f"get_selected_rows() returned: {result=}") if not selected_indexes:
return result return []
return sorted(list(set([self.model().mapToSource(a).row() for a in selected_indexes])))
def get_top_visible_row(self) -> int: def get_top_visible_row(self) -> int:
""" """

288
app/querylistmodel.py Normal file
View File

@ -0,0 +1,288 @@
# Standard library imports
# Allow forward reference to PlaylistModel
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import Optional
import datetime as dt
# PyQt imports
from PyQt6.QtCore import (
QAbstractTableModel,
QModelIndex,
Qt,
QVariant,
)
from PyQt6.QtGui import (
QColor,
QFont,
)
# Third party imports
from sqlalchemy.orm.session import Session
# import snoop # type: ignore
# App imports
from classes import (
ApplicationError,
Filter,
QueryCol,
)
from config import Config
from helpers import (
file_is_unreadable,
get_relative_date,
ms_to_mmss,
show_warning,
)
from log import log
from models import db, Playdates, Tracks
from music_manager import RowAndTrack
@dataclass
class QueryRow:
artist: str
bitrate: int
duration: int
lastplayed: Optional[dt.datetime]
path: str
title: str
track_id: int
class QuerylistModel(QAbstractTableModel):
"""
The Querylist Model
Used to support query lists. The underlying database is never
updated. We just present tracks that match a query and allow the user
to copy those to a playlist.
"""
def __init__(self, session: Session, filter: Filter) -> None:
"""
Load query
"""
log.debug(f"QuerylistModel.__init__({filter=})")
super().__init__()
self.session = session
self.filter = filter
self.querylist_rows: dict[int, QueryRow] = {}
self._selected_rows: set[int] = set()
self.load_data()
def __repr__(self) -> str:
return f"<QuerylistModel: filter={self.filter}, {self.rowCount()} rows>"
def background_role(self, row: int, column: int, qrow: QueryRow) -> QVariant:
"""Return background setting"""
# Unreadable track file
if file_is_unreadable(qrow.path):
return QVariant(QColor(Config.COLOUR_UNREADABLE))
# Selected row
if row in self._selected_rows:
return QVariant(QColor(Config.COLOUR_QUERYLIST_SELECTED))
# Individual cell colouring
if column == QueryCol.BITRATE.value:
if not qrow.bitrate or qrow.bitrate < Config.BITRATE_LOW_THRESHOLD:
return QVariant(QColor(Config.COLOUR_BITRATE_LOW))
elif qrow.bitrate < Config.BITRATE_OK_THRESHOLD:
return QVariant(QColor(Config.COLOUR_BITRATE_MEDIUM))
else:
return QVariant(QColor(Config.COLOUR_BITRATE_OK))
return QVariant()
def columnCount(self, parent: QModelIndex = QModelIndex()) -> int:
"""Standard function for view"""
return len(QueryCol)
def data(
self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole
) -> QVariant:
"""Return data to view"""
if not index.isValid() or not (0 <= index.row() < len(self.querylist_rows)):
return QVariant()
row = index.row()
column = index.column()
# rat for playlist row data as it's used a lot
qrow = self.querylist_rows[row]
# Dispatch to role-specific functions
dispatch_table: dict[int, Callable] = {
int(Qt.ItemDataRole.BackgroundRole): self.background_role,
int(Qt.ItemDataRole.DisplayRole): self.display_role,
int(Qt.ItemDataRole.ToolTipRole): self.tooltip_role,
}
if role in dispatch_table:
return QVariant(dispatch_table[role](row, column, qrow))
# Document other roles but don't use them
if role in [
Qt.ItemDataRole.DecorationRole,
Qt.ItemDataRole.EditRole,
Qt.ItemDataRole.FontRole,
Qt.ItemDataRole.ForegroundRole,
Qt.ItemDataRole.InitialSortOrderRole,
Qt.ItemDataRole.SizeHintRole,
Qt.ItemDataRole.StatusTipRole,
Qt.ItemDataRole.TextAlignmentRole,
Qt.ItemDataRole.ToolTipRole,
Qt.ItemDataRole.WhatsThisRole,
]:
return QVariant()
# Fall through to no-op
return QVariant()
def display_role(self, row: int, column: int, qrow: QueryRow) -> QVariant:
"""
Return text for display
"""
dispatch_table = {
QueryCol.ARTIST.value: QVariant(qrow.artist),
QueryCol.BITRATE.value: QVariant(qrow.bitrate),
QueryCol.DURATION.value: QVariant(ms_to_mmss(qrow.duration)),
QueryCol.LAST_PLAYED.value: QVariant(get_relative_date(qrow.lastplayed)),
QueryCol.TITLE.value: QVariant(qrow.title),
}
if column in dispatch_table:
return dispatch_table[column]
return QVariant()
def flags(self, index: QModelIndex) -> Qt.ItemFlag:
"""
Standard model flags
"""
if not index.isValid():
return Qt.ItemFlag.NoItemFlags
return Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable
def get_selected_track_ids(self) -> list[int]:
"""
Return a list of track_ids from selected tracks
"""
return [self.querylist_rows[row].track_id for row in self._selected_rows]
def headerData(
self,
section: int,
orientation: Qt.Orientation,
role: int = Qt.ItemDataRole.DisplayRole,
) -> QVariant:
"""
Return text for headers
"""
display_dispatch_table = {
QueryCol.TITLE.value: QVariant(Config.HEADER_TITLE),
QueryCol.ARTIST.value: QVariant(Config.HEADER_ARTIST),
QueryCol.DURATION.value: QVariant(Config.HEADER_DURATION),
QueryCol.LAST_PLAYED.value: QVariant(Config.HEADER_LAST_PLAYED),
QueryCol.BITRATE.value: QVariant(Config.HEADER_BITRATE),
}
if role == Qt.ItemDataRole.DisplayRole:
if orientation == Qt.Orientation.Horizontal:
return display_dispatch_table[section]
else:
if Config.ROWS_FROM_ZERO:
return QVariant(str(section))
else:
return QVariant(str(section + 1))
elif role == Qt.ItemDataRole.FontRole:
boldfont = QFont()
boldfont.setBold(True)
return QVariant(boldfont)
return QVariant()
def load_data(self) -> None:
"""
Populate self.querylist_rows
"""
# Clear any exsiting rows
self.querylist_rows = {}
row = 0
try:
results = Tracks.get_filtered_tracks(self.session, self.filter)
for result in results:
lastplayed = None
if hasattr(result, "playdates"):
pds = result.playdates
if pds:
lastplayed = max([a.lastplayed for a in pds])
queryrow = QueryRow(
artist=result.artist,
bitrate=result.bitrate or 0,
duration=result.duration,
lastplayed=lastplayed,
path=result.path,
title=result.title,
track_id=result.id,
)
self.querylist_rows[row] = queryrow
row += 1
except ApplicationError as e:
show_warning(None, "Query error", f"Error loading query data ({e})")
def rowCount(self, index: QModelIndex = QModelIndex()) -> int:
"""Standard function for view"""
return len(self.querylist_rows)
def toggle_row_selection(self, row: int) -> None:
if row in self._selected_rows:
self._selected_rows.discard(row)
else:
self._selected_rows.add(row)
# Emit dataChanged for the entire row
top_left = self.index(row, 0)
bottom_right = self.index(row, self.columnCount() - 1)
self.dataChanged.emit(top_left, bottom_right, [Qt.ItemDataRole.BackgroundRole])
def tooltip_role(self, row: int, column: int, rat: RowAndTrack) -> QVariant:
"""
Return tooltip. Currently only used for last_played column.
"""
if column != QueryCol.LAST_PLAYED.value:
return QVariant()
with db.Session() as session:
track_id = self.querylist_rows[row].track_id
if not track_id:
return QVariant()
playdates = Playdates.last_playdates(session, track_id)
return QVariant(
"<br>".join(
[
a.lastplayed.strftime(Config.LAST_PLAYED_TOOLTIP_DATE_FORMAT)
for a in reversed(playdates)
]
)
)

94
app/ui/dlgQuery.ui Normal file
View File

@ -0,0 +1,94 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>queryDialog</class>
<widget class="QDialog" name="queryDialog">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>762</width>
<height>686</height>
</rect>
</property>
<property name="windowTitle">
<string>Query</string>
</property>
<widget class="QTableView" name="tableView">
<property name="geometry">
<rect>
<x>10</x>
<y>65</y>
<width>741</width>
<height>561</height>
</rect>
</property>
</widget>
<widget class="QLabel" name="label">
<property name="geometry">
<rect>
<x>20</x>
<y>10</y>
<width>61</width>
<height>24</height>
</rect>
</property>
<property name="text">
<string>Query:</string>
</property>
</widget>
<widget class="QComboBox" name="cboQuery">
<property name="geometry">
<rect>
<x>80</x>
<y>10</y>
<width>221</width>
<height>32</height>
</rect>
</property>
</widget>
<widget class="QPushButton" name="btnAddTracks">
<property name="geometry">
<rect>
<x>530</x>
<y>640</y>
<width>102</width>
<height>36</height>
</rect>
</property>
<property name="text">
<string>Add &amp;tracks</string>
</property>
</widget>
<widget class="QLabel" name="lblDescription">
<property name="geometry">
<rect>
<x>330</x>
<y>10</y>
<width>401</width>
<height>46</height>
</rect>
</property>
<property name="text">
<string>TextLabel</string>
</property>
<property name="alignment">
<set>Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop</set>
</property>
</widget>
<widget class="QPushButton" name="pushButton">
<property name="geometry">
<rect>
<x>650</x>
<y>640</y>
<width>102</width>
<height>36</height>
</rect>
</property>
<property name="text">
<string>Close</string>
</property>
</widget>
</widget>
<resources/>
<connections/>
</ui>

45
app/ui/dlgQuery_ui.py Normal file
View File

@ -0,0 +1,45 @@
# Form implementation generated from reading ui file 'app/ui/dlgQuery.ui'
#
# Created by: PyQt6 UI code generator 6.8.1
#
# WARNING: Any manual changes made to this file will be lost when pyuic6 is
# run again. Do not edit this file unless you know what you are doing.
from PyQt6 import QtCore, QtGui, QtWidgets
class Ui_queryDialog(object):
def setupUi(self, queryDialog):
queryDialog.setObjectName("queryDialog")
queryDialog.resize(762, 686)
self.tableView = QtWidgets.QTableView(parent=queryDialog)
self.tableView.setGeometry(QtCore.QRect(10, 65, 741, 561))
self.tableView.setObjectName("tableView")
self.label = QtWidgets.QLabel(parent=queryDialog)
self.label.setGeometry(QtCore.QRect(20, 10, 61, 24))
self.label.setObjectName("label")
self.cboQuery = QtWidgets.QComboBox(parent=queryDialog)
self.cboQuery.setGeometry(QtCore.QRect(80, 10, 221, 32))
self.cboQuery.setObjectName("cboQuery")
self.btnAddTracks = QtWidgets.QPushButton(parent=queryDialog)
self.btnAddTracks.setGeometry(QtCore.QRect(530, 640, 102, 36))
self.btnAddTracks.setObjectName("btnAddTracks")
self.lblDescription = QtWidgets.QLabel(parent=queryDialog)
self.lblDescription.setGeometry(QtCore.QRect(330, 10, 401, 46))
self.lblDescription.setAlignment(QtCore.Qt.AlignmentFlag.AlignLeading|QtCore.Qt.AlignmentFlag.AlignLeft|QtCore.Qt.AlignmentFlag.AlignTop)
self.lblDescription.setObjectName("lblDescription")
self.pushButton = QtWidgets.QPushButton(parent=queryDialog)
self.pushButton.setGeometry(QtCore.QRect(650, 640, 102, 36))
self.pushButton.setObjectName("pushButton")
self.retranslateUi(queryDialog)
QtCore.QMetaObject.connectSlotsByName(queryDialog)
def retranslateUi(self, queryDialog):
_translate = QtCore.QCoreApplication.translate
queryDialog.setWindowTitle(_translate("queryDialog", "Query"))
self.label.setText(_translate("queryDialog", "Query:"))
self.btnAddTracks.setText(_translate("queryDialog", "Add &tracks"))
self.lblDescription.setText(_translate("queryDialog", "TextLabel"))
self.pushButton.setText(_translate("queryDialog", "Close"))

View File

@ -1,6 +1,7 @@
<RCC> <RCC>
<qresource prefix="icons"> <qresource prefix="icons">
<file>yellow-circle.png</file> <file>yellow-circle.png</file>
<file>redstar.png</file>
<file>green-circle.png</file> <file>green-circle.png</file>
<file>star.png</file> <file>star.png</file>
<file>star_empty.png</file> <file>star_empty.png</file>

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

BIN
app/ui/redstar.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View File

@ -1,73 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""Tests the audacity pipe.
Keep pipe_test.py short!!
You can make more complicated longer tests to test other functionality
or to generate screenshots etc in other scripts.
Make sure Audacity is running first and that mod-script-pipe is enabled
before running this script.
Requires Python 2.7 or later. Python 3 is strongly recommended.
"""
import os
import sys
if sys.platform == 'win32':
print("pipe-test.py, running on windows")
TONAME = '\\\\.\\pipe\\ToSrvPipe'
FROMNAME = '\\\\.\\pipe\\FromSrvPipe'
EOL = '\r\n\0'
else:
print("pipe-test.py, running on linux or mac")
TONAME = '/tmp/audacity_script_pipe.to.' + str(os.getuid())
FROMNAME = '/tmp/audacity_script_pipe.from.' + str(os.getuid())
EOL = '\n'
print("Write to \"" + TONAME + "\"")
if not os.path.exists(TONAME):
print(" does not exist. Ensure Audacity is running with mod-script-pipe.")
sys.exit()
print("Read from \"" + FROMNAME + "\"")
if not os.path.exists(FROMNAME):
print(" does not exist. Ensure Audacity is running with mod-script-pipe.")
sys.exit()
print("-- Both pipes exist. Good.")
TOFILE = open(TONAME, 'w')
print("-- File to write to has been opened")
FROMFILE = open(FROMNAME, 'rt')
print("-- File to read from has now been opened too\r\n")
def send_command(command):
"""Send a single command."""
print("Send: >>> \n"+command)
TOFILE.write(command + EOL)
TOFILE.flush()
def get_response():
"""Return the command response."""
result = ''
line = ''
while True:
result += line
line = FROMFILE.readline()
if line == '\n' and len(result) > 0:
break
return result
def do_command(command):
"""Send one command, and return the response."""
send_command(command)
response = get_response()
print("Rcvd: <<< \n" + response)
return response
do_command('Import2: Filename=/home/kae/git/musicmuster/archive/boot.flac')

View File

@ -1 +0,0 @@
Run Flake8 and Black

View File

@ -0,0 +1,58 @@
"""add favouirit to playlists
Revision ID: 04df697e40cd
Revises: 33c04e3c12c8
Create Date: 2025-02-22 20:20:45.030024
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = '04df697e40cd'
down_revision = '33c04e3c12c8'
branch_labels = None
depends_on = None
def upgrade(engine_name: str) -> None:
globals()["upgrade_%s" % engine_name]()
def downgrade(engine_name: str) -> None:
globals()["downgrade_%s" % engine_name]()
def upgrade_() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('notecolours', schema=None) as batch_op:
batch_op.add_column(sa.Column('strip_substring', sa.Boolean(), nullable=False))
batch_op.create_index(batch_op.f('ix_notecolours_substring'), ['substring'], unique=False)
with op.batch_alter_table('playlist_rows', schema=None) as batch_op:
batch_op.drop_constraint('playlist_rows_ibfk_1', type_='foreignkey')
with op.batch_alter_table('playlists', schema=None) as batch_op:
batch_op.add_column(sa.Column('favourite', sa.Boolean(), nullable=False))
# ### end Alembic commands ###
def downgrade_() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('playlists', schema=None) as batch_op:
batch_op.drop_column('favourite')
with op.batch_alter_table('playlist_rows', schema=None) as batch_op:
batch_op.create_foreign_key('playlist_rows_ibfk_1', 'tracks', ['track_id'], ['id'])
with op.batch_alter_table('notecolours', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_notecolours_substring'))
batch_op.drop_column('strip_substring')
# ### end Alembic commands ###

View File

@ -0,0 +1,47 @@
"""create queries table
Revision ID: 4fc2a9a82ab0
Revises: ab475332d873
Create Date: 2025-02-26 13:13:25.118489
"""
from alembic import op
import sqlalchemy as sa
import dbtables
# revision identifiers, used by Alembic.
revision = '4fc2a9a82ab0'
down_revision = 'ab475332d873'
branch_labels = None
depends_on = None
def upgrade(engine_name: str) -> None:
globals()["upgrade_%s" % engine_name]()
def downgrade(engine_name: str) -> None:
globals()["downgrade_%s" % engine_name]()
def upgrade_() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('queries',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('name', sa.String(length=128), nullable=False),
sa.Column('filter_data', dbtables.JSONEncodedDict(), nullable=False),
sa.Column('favourite', sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade_() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('queries')
# ### end Alembic commands ###

View File

@ -0,0 +1,46 @@
"""fix playlist cascades
Revision ID: ab475332d873
Revises: 04df697e40cd
Create Date: 2025-02-26 13:11:15.417278
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'ab475332d873'
down_revision = '04df697e40cd'
branch_labels = None
depends_on = None
def upgrade(engine_name: str) -> None:
globals()["upgrade_%s" % engine_name]()
def downgrade(engine_name: str) -> None:
globals()["downgrade_%s" % engine_name]()
def upgrade_() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('playdates', schema=None) as batch_op:
batch_op.drop_constraint('fk_playdates_track_id_tracks', type_='foreignkey')
batch_op.create_foreign_key(None, 'tracks', ['track_id'], ['id'], ondelete='CASCADE')
# ### end Alembic commands ###
def downgrade_() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('playdates', schema=None) as batch_op:
batch_op.drop_constraint(None, type_='foreignkey')
batch_op.create_foreign_key('fk_playdates_track_id_tracks', 'tracks', ['track_id'], ['id'])
# ### end Alembic commands ###

View File

@ -1,6 +0,0 @@
#!/bin/bash
# cd /home/kae/mm
# MYSQL_CONNECT="mysql+mysqldb://musicmuster:musicmuster@localhost/musicmuster_prod" ROOT="/home/kae/music" direnv exec .
for file in "$@"; do
app/songdb.py -i "$file"
done

2048
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -2,11 +2,9 @@
name = "musicmuster" name = "musicmuster"
version = "4.1.10" version = "4.1.10"
description = "Music player for internet radio" description = "Music player for internet radio"
authors = [ authors = [{ name = "Keith Edmunds", email = "kae@midnighthax.com" }]
{ name = "Keith Edmunds", email = "kae@midnighthax.com" } requires-python = ">=3.13,<4"
]
readme = "README.md" readme = "README.md"
requires-python = ">=3.11,<4.0"
dependencies = [ dependencies = [
"alchemical>=1.0.2", "alchemical>=1.0.2",
"alembic>=1.14.0", "alembic>=1.14.0",
@ -31,27 +29,30 @@ dependencies = [
"tinytag>=1.10.1", "tinytag>=1.10.1",
"types-psutil>=6.0.0.20240621", "types-psutil>=6.0.0.20240621",
"pyyaml (>=6.0.2,<7.0.0)", "pyyaml (>=6.0.2,<7.0.0)",
"audioop-lts>=0.2.1",
"types-pyyaml>=6.0.12.20241230",
] ]
[dependency-groups]
dev = [
"flakehell>=0.9.0,<0.10",
"ipdb>=0.13.9,<0.14",
"line-profiler>=4.2.0,<5",
"mypy>=1.15.0,<2",
"pudb",
"pydub-stubs>=0.25.1,<0.26",
"pytest>=8.3.4,<9",
"pytest-qt>=4.4.0,<5",
"black>=25.1.0,<26",
"pytest-cov>=6.0.0,<7",
]
[tool.poetry] [tool.uv]
package-mode = false package = false
[tool.poetry.group.dev.dependencies]
flakehell = "^0.9.0"
ipdb = "^0.13.9"
line-profiler = "^4.2.0"
mypy = "^1.15.0"
pudb = "*"
pydub-stubs = "^0.25.1"
pytest = "^8.3.4"
pytest-qt = "^4.4.0"
black = "^25.1.0"
pytest-cov = "^6.0.0"
[build-system] [build-system]
requires = ["poetry-core>=1.0.0"] requires = ["hatchling"]
build-backend = "poetry.core.masonry.api" build-backend = "hatchling.build"
[tool.mypy] [tool.mypy]
mypy_path = "/home/kae/git/musicmuster/app" mypy_path = "/home/kae/git/musicmuster/app"

View File

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

View File

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

View File

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

130
tests/test_queries.py Normal file
View File

@ -0,0 +1,130 @@
# Standard library imports
import datetime as dt
import unittest
# PyQt imports
# Third party imports
# App imports
from app.models import (
db,
Playdates,
Tracks,
)
from classes import (
Filter,
)
class MyTestCase(unittest.TestCase):
@classmethod
def setUpClass(cls):
"""Runs once before any test in this class"""
db.create_all()
with db.Session() as session:
# Create some track entries
_ = Tracks(**dict(
session=session,
artist="a",
bitrate=0,
duration=100,
fade_at=0,
path="/alpha/bravo/charlie",
silence_at=0,
start_gap=0,
title="abc"
))
track2 = Tracks(**dict(
session=session,
artist="a",
bitrate=0,
duration=100,
fade_at=0,
path="/xray/yankee/zulu",
silence_at=0,
start_gap=0,
title="xyz"
))
track2_id = track2.id
# Add playdates
# Track 2 played just over a year ago
just_over_a_year_ago = dt.datetime.now() - dt.timedelta(days=367)
_ = Playdates(session, track2_id, when=just_over_a_year_ago)
@classmethod
def tearDownClass(cls):
"""Runs once after all tests"""
db.drop_all()
def setUp(self):
"""Runs before each test"""
pass
def tearDown(self):
"""Runs after each test"""
pass
def test_search_path_1(self):
"""Search for unplayed track"""
filter = Filter(path="alpha", last_played_comparator="never")
with db.Session() as session:
results = Tracks.get_filtered_tracks(session, filter)
assert len(results) == 1
assert 'alpha' in results[0].path
def test_search_path_2(self):
"""Search for unplayed track that doesn't exist"""
filter = Filter(path="xray", last_played_comparator="never")
with db.Session() as session:
results = Tracks.get_filtered_tracks(session, filter)
assert len(results) == 0
def test_played_over_a_year_ago(self):
"""Search for tracks played over a year ago"""
filter = Filter(last_played_unit="years", last_played_number=1)
with db.Session() as session:
results = Tracks.get_filtered_tracks(session, filter)
assert len(results) == 1
assert 'zulu' in results[0].path
def test_played_over_two_years_ago(self):
"""Search for tracks played over 2 years ago"""
filter = Filter(last_played_unit="years", last_played_number=2)
with db.Session() as session:
results = Tracks.get_filtered_tracks(session, filter)
assert len(results) == 0
def test_never_played(self):
"""Search for tracks never played"""
filter = Filter(last_played_comparator="never")
with db.Session() as session:
results = Tracks.get_filtered_tracks(session, filter)
assert len(results) == 1
assert 'alpha' in results[0].path
def test_played_anytime(self):
"""Search for tracks played over a year ago"""
filter = Filter(last_played_comparator="Any time")
with db.Session() as session:
results = Tracks.get_filtered_tracks(session, filter)
assert len(results) == 1
assert 'zulu' in results[0].path

View File

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

1185
uv.lock Normal file

File diff suppressed because it is too large Load Diff