Compare commits

..

No commits in common. "master" and "v3.99.22" have entirely different histories.

94 changed files with 8036 additions and 12873 deletions

5
.envrc
View File

@ -1,5 +1,4 @@
layout uv layout poetry
export LINE_PROFILE=1
export MAIL_PASSWORD="ewacyay5seu2qske" export MAIL_PASSWORD="ewacyay5seu2qske"
export MAIL_PORT=587 export MAIL_PORT=587
export MAIL_SERVER="smtp.fastmail.com" export MAIL_SERVER="smtp.fastmail.com"
@ -13,8 +12,6 @@ branch=$(git branch --show-current)
if [ $(pwd) == /home/kae/mm ]; then if [ $(pwd) == /home/kae/mm ]; then
export MM_ENV="PRODUCTION" export MM_ENV="PRODUCTION"
export DATABASE_URL="mysql+mysqldb://musicmuster:musicmuster@localhost/musicmuster_prod" export DATABASE_URL="mysql+mysqldb://musicmuster:musicmuster@localhost/musicmuster_prod"
# on_git_branch is a direnv directive
# See https://github.com/direnv/direnv/blob/master/man/direnv-stdlib.1.md
elif on_git_branch master; then elif on_git_branch master; then
export MM_ENV="PRODUCTION" export MM_ENV="PRODUCTION"
export DATABASE_URL="mysql+mysqldb://musicmuster:musicmuster@localhost/musicmuster_prod" export DATABASE_URL="mysql+mysqldb://musicmuster:musicmuster@localhost/musicmuster_prod"

1
.gitattributes vendored
View File

@ -1 +0,0 @@
*.py diff=python

3
.gitignore vendored
View File

@ -2,7 +2,6 @@
*.pyc *.pyc
*.swp *.swp
tags tags
.venv/
venv/ venv/
Session.vim Session.vim
*.flac *.flac
@ -13,5 +12,3 @@ StudioPlaylist.png
.direnv .direnv
tmp/ tmp/
.coverage .coverage
profile_output*
kae.py

View File

@ -1 +1 @@
3.13 musicmuster

View File

@ -0,0 +1,78 @@
#!/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

@ -0,0 +1,94 @@
#!/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

@ -1,155 +0,0 @@
# Standard library imports
import os
import psutil
import socket
import select
from typing import Optional
# PyQt imports
# Third party imports
# App imports
from classes import ApplicationError
from config import Config
from log import log
class AudacityController:
def __init__(
self,
method: str = "pipe",
socket_host: str = "localhost",
socket_port: int = 12345,
timeout: int = Config.AUDACITY_TIMEOUT_SECONDS,
) -> None:
"""
Initialize the AudacityController.
:param method: Communication method ('pipe' or 'socket').
:param socket_host: Host for socket connection (if using sockets).
:param socket_port: Port for socket connection (if using sockets).
:param timeout: Timeout in seconds for pipe operations.
"""
self.method = method
self.path: Optional[str] = None
self.timeout = timeout
if method == "pipe":
user_uid = os.getuid() # Get the user's UID
self.pipe_to = f"/tmp/audacity_script_pipe.to.{user_uid}"
self.pipe_from = f"/tmp/audacity_script_pipe.from.{user_uid}"
elif method == "socket":
self.socket_host = socket_host
self.socket_port = socket_port
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
self.sock.connect((self.socket_host, self.socket_port))
self.sock.settimeout(self.timeout)
except socket.error as e:
raise ApplicationError(f"Failed to connect to Audacity socket: {e}")
else:
raise ApplicationError("Invalid method. Use 'pipe' or 'socket'.")
self._sanity_check()
def close(self):
"""
Close the connection (for sockets).
"""
if self.method == "socket":
self.sock.close()
def export(self) -> None:
"""
Export file from Audacity
"""
self._sanity_check()
select_status = self._send_command("SelectAll")
log.debug(f"{select_status=}")
# Escape any double quotes in filename
export_cmd = f'Export2: Filename="{self.path.replace('"', '\\"')}" NumChannels=2'
export_status = self._send_command(export_cmd)
log.debug(f"{export_status=}")
self.path = ""
if not export_status.startswith("Exported"):
raise ApplicationError(f"Error writing from Audacity: {export_status=}")
def open(self, path: str) -> None:
"""
Open path in Audacity. Escape filename.
"""
self._sanity_check()
escaped_path = path.replace('"', '\\"')
cmd = f'Import2: Filename="{escaped_path}"'
status = self._send_command(cmd)
self.path = path
log.debug(f"_open_in_audacity {path=}, {status=}")
def _sanity_check(self) -> None:
"""
Check Audactity running and basic connectivity.
"""
# Check Audacity is running
if "audacity" not in [i.name() for i in psutil.process_iter()]:
log.warning("Audactity not running")
raise ApplicationError("Audacity is not running")
# Check pipes exist
if self.method == "pipe":
if not (os.path.exists(self.pipe_to) and os.path.exists(self.pipe_from)):
raise ApplicationError(
"AudacityController: Audacity pipes not found. Ensure scripting is enabled "
f"and pipes exist at {self.pipe_to} and {self.pipe_from}."
)
def _test_connectivity(self) -> None:
"""
Send test command to Audacity
"""
response = self._send_command(Config.AUDACITY_TEST_COMMAND)
if response != Config.AUDACITY_TEST_RESPONSE:
raise ApplicationError(
"Error testing Audacity connectivity\n"
f"Sent: {Config.AUDACITY_TEST_COMMAND}"
f"Received: {response}"
)
def _send_command(self, command: str) -> str:
"""
Send a command to Audacity.
:param command: Command to send (e.g., 'SelectAll').
:return: Response from Audacity.
"""
log.debug(f"_send_command({command=})")
if self.method == "pipe":
try:
with open(self.pipe_to, "w") as to_pipe:
to_pipe.write(command + "\n")
with open(self.pipe_from, "r") as from_pipe:
ready, _, _ = select.select([from_pipe], [], [], self.timeout)
if ready:
response = from_pipe.readline()
else:
raise TimeoutError(
f"Timeout waiting for response from {self.pipe_from}"
)
except Exception as e:
raise RuntimeError(f"Error communicating with Audacity via pipes: {e}")
elif self.method == "socket":
try:
self.sock.sendall((command + "\n").encode("utf-8"))
response = self.sock.recv(1024).decode("utf-8")
except socket.timeout:
raise TimeoutError("Timeout waiting for response from Audacity socket.")
except Exception as e:
raise RuntimeError(f"Error communicating with Audacity via socket: {e}")
return response.strip()

View File

@ -1,63 +1,15 @@
# Standard library imports # Standard library imports
from __future__ import annotations from dataclasses import dataclass, field
from dataclasses import dataclass
from enum import auto, Enum from enum import auto, Enum
import functools from typing import Any, Optional, NamedTuple
import threading
from typing import NamedTuple # PyQt imports
from PyQt6.QtCore import pyqtSignal, QObject
# Third party imports # Third party imports
# PyQt imports
from PyQt6.QtCore import (
pyqtSignal,
QObject,
)
from PyQt6.QtWidgets import (
QProxyStyle,
QStyle,
QStyleOption,
)
# App imports # App imports
import helpers
# Define singleton first as it's needed below
def singleton(cls):
"""
Make a class a Singleton class (see
https://realpython.com/primer-on-python-decorators/#creating-singletons)
Added locking.
"""
lock = threading.Lock()
@functools.wraps(cls)
def wrapper_singleton(*args, **kwargs):
if wrapper_singleton.instance is None:
with lock:
if wrapper_singleton.instance is None: # Check still None
wrapper_singleton.instance = cls(*args, **kwargs)
return wrapper_singleton.instance
wrapper_singleton.instance = None
return wrapper_singleton
class ApplicationError(Exception):
"""
Custom exception
"""
pass
class AudioMetadata(NamedTuple):
start_gap: int = 0
silence_at: int = 0
fade_at: int = 0
class Col(Enum): class Col(Enum):
@ -73,31 +25,16 @@ class Col(Enum):
NOTE = auto() NOTE = auto()
class FileErrors(NamedTuple): @helpers.singleton
path: str
error: str
@dataclass
class Filter:
version: int = 1
path_type: str = "contains"
path: str = ""
last_played_number: int = 0
last_played_comparator: str = "before"
last_played_unit: str = "years"
duration_type: str = "longer than"
duration_number: int = 0
duration_unit: str = "minutes"
@singleton
@dataclass @dataclass
class MusicMusterSignals(QObject): class MusicMusterSignals(QObject):
""" """
Class for all MusicMuster signals. See: Class for all MusicMuster signals. See:
- https://zetcode.com/gui/pyqt5/eventssignals/ - https://zetcode.com/gui/pyqt5/eventssignals/
- https://stackoverflow.com/questions/62654525/emit-a-signal-from-another-class-to-main-class - https://stackoverflow.com/questions/62654525/
emit-a-signal-from-another-class-to-main-class
and Singleton class at
https://refactoring.guru/design-patterns/singleton/python/example#example-0
""" """
begin_reset_model_signal = pyqtSignal(int) begin_reset_model_signal = pyqtSignal(int)
@ -116,37 +53,18 @@ class MusicMusterSignals(QObject):
super().__init__() super().__init__()
class PlaylistStyle(QProxyStyle): @dataclass
def drawPrimitive(self, element, option, painter, widget=None): class TrackFileData:
""" """
Draw a line across the entire row rather than just the column Simple class to track details changes to a track file
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)
new_file_path: str
class QueryCol(Enum): track_id: int = 0
TITLE = 0 track_path: Optional[str] = None
ARTIST = auto() obsolete_path: Optional[str] = None
DURATION = auto() tags: dict[str, Any] = field(default_factory=dict)
LAST_PLAYED = auto() audio_metadata: dict[str, str | int | float] = field(default_factory=dict)
BITRATE = auto()
class Tags(NamedTuple):
artist: str = ""
title: str = ""
bitrate: int = 0
duration: int = 0
class TrackInfo(NamedTuple): class TrackInfo(NamedTuple):

View File

@ -2,6 +2,7 @@
import datetime as dt import datetime as dt
import logging import logging
import os import os
from typing import List, Optional
# PyQt imports # PyQt imports
@ -11,9 +12,7 @@ import os
class Config(object): class Config(object):
AUDACITY_TEST_COMMAND = "Message" AUDACITY_TIMEOUT_TENTHS = 100
AUDACITY_TEST_RESPONSE = "Some message"
AUDACITY_TIMEOUT_SECONDS = 20
AUDIO_SEGMENT_CHUNK_SIZE = 10 AUDIO_SEGMENT_CHUNK_SIZE = 10
BITRATE_LOW_THRESHOLD = 192 BITRATE_LOW_THRESHOLD = 192
BITRATE_OK_THRESHOLD = 300 BITRATE_OK_THRESHOLD = 300
@ -31,42 +30,23 @@ 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
DEBUG_FUNCTIONS: List[Optional[str]] = []
DEBUG_MODULES: List[Optional[str]] = []
DEFAULT_COLUMN_WIDTH = 200 DEFAULT_COLUMN_WIDTH = 200
DISPLAY_SQL = False DISPLAY_SQL = False
DO_NOT_IMPORT = "Do not import"
ENGINE_OPTIONS = dict(pool_pre_ping=True)
# ENGINE_OPTIONS = dict(pool_pre_ping=True, echo=True)
EPOCH = dt.datetime(1970, 1, 1) EPOCH = dt.datetime(1970, 1, 1)
ENGINE_OPTIONS = dict(pool_pre_ping=True)
ERRORS_FROM = ["noreply@midnighthax.com"] ERRORS_FROM = ["noreply@midnighthax.com"]
ERRORS_TO = ["kae@midnighthax.com"] ERRORS_TO = ["kae@midnighthax.com"]
EXTERNAL_BROWSER_PATH = "/usr/bin/vivaldi"
FADE_CURVE_BACKGROUND = "lightyellow" FADE_CURVE_BACKGROUND = "lightyellow"
FADE_CURVE_FOREGROUND = "blue" FADE_CURVE_FOREGROUND = "blue"
FADE_CURVE_MS_BEFORE_FADE = 5000 FADE_CURVE_MS_BEFORE_FADE = 5000
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_SELECT_ARTIST = 80.0
FUZZYMATCH_MINIMUM_SELECT_TITLE = 80.0
FUZZYMATCH_SHOW_SCORES = True
HEADER_ARTIST = "Artist" HEADER_ARTIST = "Artist"
HEADER_BITRATE = "bps" HEADER_BITRATE = "bps"
HEADER_DURATION = "Length" HEADER_DURATION = "Length"
@ -78,9 +58,6 @@ class Config(object):
HEADER_START_TIME = "Start" HEADER_START_TIME = "Start"
HEADER_TITLE = "Title" HEADER_TITLE = "Title"
HIDE_AFTER_PLAYING_OFFSET = 5000 HIDE_AFTER_PLAYING_OFFSET = 5000
HIDE_PLAYED_MODE_SECTIONS = "SECTIONS"
HIDE_PLAYED_MODE_TRACKS = "TRACKS"
IMPORT_AS_NEW = "Import as new track"
INFO_TAB_TITLE_LENGTH = 15 INFO_TAB_TITLE_LENGTH = 15
INTRO_SECONDS_FORMAT = ".1f" INTRO_SECONDS_FORMAT = ".1f"
INTRO_SECONDS_WARNING_MS = 3000 INTRO_SECONDS_WARNING_MS = 3000
@ -94,52 +71,37 @@ class Config(object):
MAIL_SERVER = os.environ.get("MAIL_SERVER") or "woodlands.midnighthax.com" MAIL_SERVER = os.environ.get("MAIL_SERVER") or "woodlands.midnighthax.com"
MAIL_USERNAME = os.environ.get("MAIL_USERNAME") MAIL_USERNAME = os.environ.get("MAIL_USERNAME")
MAIL_USE_TLS = os.environ.get("MAIL_USE_TLS") is not None MAIL_USE_TLS = os.environ.get("MAIL_USE_TLS") is not None
MAIN_WINDOW_TITLE = "MusicMuster"
MAX_IMPORT_MATCHES = 5 MAX_IMPORT_MATCHES = 5
MAX_IMPORT_THREADS = 3
MAX_INFO_TABS = 5 MAX_INFO_TABS = 5
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"
NOTE_TIME_FORMAT = "%H:%M" NOTE_TIME_FORMAT = "%H:%M"
OBS_HOST = "localhost" OBS_HOST = "localhost"
OBS_PASSWORD = "auster" OBS_PASSWORD = "auster"
OBS_PORT = 4455 OBS_PORT = 4455
PLAY_NEXT_GUARD_MS = 10000 PLAY_NEXT_GUARD_MS = 10000
PLAY_SETTLE = 500000 PLAY_SETTLE = 500000
PLAYLIST_ICON_CURRENT = ":/icons/green-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
REPLACE_FILES_DEFAULT_SOURCE = "/home/kae/music/Singles/tmp" REPLACE_FILES_DEFAULT_SOURCE = "/home/kae/music/Singles/tmp"
RESIZE_ROW_CHUNK_SIZE = 40 RETURN_KEY_DEBOUNCE_MS = 500
RETURN_KEY_DEBOUNCE_MS = 1000
ROOT = os.environ.get("ROOT") or "/home/kae/music" ROOT = os.environ.get("ROOT") or "/home/kae/music"
ROW_PADDING = 4
ROWS_FROM_ZERO = True ROWS_FROM_ZERO = True
SCROLL_TOP_MARGIN = 3 SCROLL_TOP_MARGIN = 3
SECTION_ENDINGS = ("-", "+-", "-+")
SECTION_HEADER = "[Section header]"
SECTION_STARTS = ("+", "+-", "-+")
SONGFACTS_ON_NEXT = False
START_GAP_WARNING_THRESHOLD = 300 START_GAP_WARNING_THRESHOLD = 300
SUBTOTAL_ON_ROW_ZERO = "[No subtotal on first row]" TEXT_NO_TRACK_NO_NOTE = "[Section header]"
TOD_TIME_FORMAT = "%H:%M:%S" TOD_TIME_FORMAT = "%H:%M:%S"
TRACK_TIME_FORMAT = "%H:%M:%S" TRACK_TIME_FORMAT = "%H:%M:%S"
VLC_MAIN_PLAYER_NAME = "MusicMuster Main Player" VLC_MAIN_PLAYER_NAME = "MusicMuster Main Player"
VLC_PREVIEW_PLAYER_NAME = "MusicMuster Preview Player" VLC_PREVIEW_PLAYER_NAME = "MusicMuster Preview Player"
VLC_VOLUME_DEFAULT = 100 VLC_VOLUME_DEFAULT = 75
VLC_VOLUME_DROP3db = 70 VLC_VOLUME_DROP3db = 65
WARNING_MS_BEFORE_FADE = 5500 WARNING_MS_BEFORE_FADE = 5500
WARNING_MS_BEFORE_SILENCE = 5500 WARNING_MS_BEFORE_SILENCE = 5500
WEB_ZOOM_FACTOR = 1.2 WEB_ZOOM_FACTOR = 1.2
WIKIPEDIA_ON_NEXT = False
# These rely on earlier definitions # These rely on earlier definitions
HIDE_PLAYED_MODE = HIDE_PLAYED_MODE_TRACKS
IMPORT_DESTINATION = os.path.join(ROOT, "Singles") IMPORT_DESTINATION = os.path.join(ROOT, "Singles")
REPLACE_FILES_DEFAULT_DESTINATION = os.path.dirname(REPLACE_FILES_DEFAULT_SOURCE) REPLACE_FILES_DEFAULT_DESTINATION = os.path.dirname(REPLACE_FILES_DEFAULT_SOURCE)

View File

@ -15,17 +15,16 @@ class DatabaseManager:
__instance = None __instance = None
def __init__(self, database_url: str, **kwargs: dict) -> None: def __init__(self, database_url: str, **kwargs):
if DatabaseManager.__instance is None: if DatabaseManager.__instance is None:
self.db = Alchemical(database_url, **kwargs) self.db = Alchemical(database_url, **kwargs)
# Database managed by Alembic so no create_all() required self.db.create_all()
# 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")
@staticmethod @staticmethod
def get_instance(database_url: str, **kwargs: dict) -> Alchemical: def get_instance(database_url: str, **kwargs):
if DatabaseManager.__instance is None: if DatabaseManager.__instance is None:
DatabaseManager(database_url, **kwargs) DatabaseManager(database_url, **kwargs)
return DatabaseManager.__instance return DatabaseManager.__instance

View File

@ -1,8 +1,6 @@
# Standard library imports # Standard library imports
from typing import Optional from typing import List, Optional
from dataclasses import asdict
import datetime as dt import datetime as dt
import json
# PyQt imports # PyQt imports
@ -15,37 +13,13 @@ 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
@ -53,14 +27,12 @@ class NoteColoursTable(Model):
__tablename__ = "notecolours" __tablename__ = "notecolours"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
substring: Mapped[str] = mapped_column(String(256), index=True, unique=True) substring: Mapped[str] = mapped_column(String(256), index=False)
colour: Mapped[str] = mapped_column(String(21), index=False) colour: Mapped[str] = mapped_column(String(21), index=False)
enabled: Mapped[bool] = mapped_column(default=True, index=True) enabled: Mapped[bool] = mapped_column(default=True, index=True)
foreground: Mapped[Optional[str]] = mapped_column(String(21), index=False)
is_regex: Mapped[bool] = mapped_column(default=False, index=False) is_regex: Mapped[bool] = mapped_column(default=False, index=False)
is_casesensitive: Mapped[bool] = mapped_column(default=False, index=False) is_casesensitive: Mapped[bool] = mapped_column(default=False, index=False)
order: Mapped[Optional[int]] = mapped_column(index=True) order: Mapped[Optional[int]] = mapped_column(index=True)
strip_substring: Mapped[bool] = mapped_column(default=True, index=False)
def __repr__(self) -> str: def __repr__(self) -> str:
return ( return (
@ -74,10 +46,9 @@ 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", ondelete="CASCADE")) track_id: Mapped[int] = mapped_column(ForeignKey("tracks.id"))
track: Mapped["TracksTable"] = relationship( track: Mapped["TracksTable"] = relationship(
"TracksTable", "TracksTable", back_populates="playdates"
back_populates="playdates",
) )
def __repr__(self) -> str: def __repr__(self) -> str:
@ -100,14 +71,12 @@ class PlaylistsTable(Model):
tab: Mapped[Optional[int]] = mapped_column(default=None) tab: Mapped[Optional[int]] = mapped_column(default=None)
open: Mapped[bool] = mapped_column(default=False) open: Mapped[bool] = mapped_column(default=False)
is_template: Mapped[bool] = mapped_column(default=False) is_template: Mapped[bool] = mapped_column(default=False)
rows: Mapped[list["PlaylistRowsTable"]] = relationship( deleted: Mapped[bool] = mapped_column(default=False)
rows: Mapped[List["PlaylistRowsTable"]] = relationship(
"PlaylistRowsTable", "PlaylistRowsTable",
back_populates="playlist", back_populates="playlist",
cascade="all, delete-orphan", cascade="all, delete-orphan",
order_by="PlaylistRowsTable.row_number", order_by="PlaylistRowsTable.plr_rownum",
)
favourite: Mapped[bool] = mapped_column(
Boolean, nullable=False, index=False, default=False
) )
def __repr__(self) -> str: def __repr__(self) -> str:
@ -121,18 +90,13 @@ class PlaylistRowsTable(Model):
__tablename__ = "playlist_rows" __tablename__ = "playlist_rows"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
row_number: Mapped[int] = mapped_column(index=True) plr_rownum: Mapped[int]
note: Mapped[str] = mapped_column( note: Mapped[str] = mapped_column(
String(2048), index=False, default="", nullable=False String(2048), index=False, default="", nullable=False
) )
playlist_id: Mapped[int] = mapped_column( playlist_id: Mapped[int] = mapped_column(ForeignKey("playlists.id"))
ForeignKey("playlists.id", ondelete="CASCADE"), index=True
)
playlist: Mapped[PlaylistsTable] = relationship(back_populates="rows") playlist: Mapped[PlaylistsTable] = relationship(back_populates="rows")
track_id: Mapped[Optional[int]] = mapped_column( track_id: Mapped[Optional[int]] = mapped_column(ForeignKey("tracks.id"))
ForeignKey("tracks.id", ondelete="CASCADE")
)
track: Mapped["TracksTable"] = relationship( track: Mapped["TracksTable"] = relationship(
"TracksTable", "TracksTable",
back_populates="playlistrows", back_populates="playlistrows",
@ -143,37 +107,12 @@ class PlaylistRowsTable(Model):
def __repr__(self) -> str: def __repr__(self) -> str:
return ( return (
f"<PlaylistRows(id={self.id}, playlist_id={self.playlist_id}, " f"<PlaylistRow(id={self.id}, playlist_id={self.playlist_id}, "
f"track_id={self.track_id}, " f"track_id={self.track_id}, "
f"note={self.note}, row_number={self.row_number}>" f"note={self.note}, plr_rownum={self.plr_rownum}>"
) )
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"""
@ -196,26 +135,23 @@ class TracksTable(Model):
__tablename__ = "tracks" __tablename__ = "tracks"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
title: Mapped[str] = mapped_column(String(256), index=True)
artist: Mapped[str] = mapped_column(String(256), index=True) artist: Mapped[str] = mapped_column(String(256), index=True)
bitrate: Mapped[int] = mapped_column(default=None) bitrate: Mapped[Optional[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)
mtime: Mapped[float] = mapped_column(index=True)
path: Mapped[str] = mapped_column(String(2048), index=False, unique=True) path: Mapped[str] = mapped_column(String(2048), index=False, unique=True)
silence_at: Mapped[int] = mapped_column(index=False) silence_at: Mapped[int] = mapped_column(index=False)
start_gap: Mapped[int] = mapped_column(index=False) start_gap: Mapped[int] = mapped_column(index=False)
title: Mapped[str] = mapped_column(String(256), index=True) playlistrows: Mapped[List[PlaylistRowsTable]] = relationship(
"PlaylistRowsTable", back_populates="track"
playlistrows: Mapped[list[PlaylistRowsTable]] = relationship(
"PlaylistRowsTable",
back_populates="track",
cascade="all, delete-orphan",
) )
playlists = association_proxy("playlistrows", "playlist") playlists = association_proxy("playlistrows", "playlist")
playdates: Mapped[list[PlaydatesTable]] = relationship( playdates: Mapped[List[PlaydatesTable]] = relationship(
"PlaydatesTable", "PlaydatesTable",
back_populates="track", back_populates="track",
cascade="all, delete-orphan",
lazy="joined", lazy="joined",
) )

View File

@ -1,5 +1,6 @@
# Standard library imports # Standard library imports
from typing import Optional from typing import Optional
import os
# PyQt imports # PyQt imports
from PyQt6.QtCore import QEvent, Qt from PyQt6.QtCore import QEvent, Qt
@ -8,22 +9,222 @@ from PyQt6.QtWidgets import (
QDialog, QDialog,
QListWidgetItem, QListWidgetItem,
QMainWindow, QMainWindow,
QTableWidgetItem,
) )
# Third party imports # Third party imports
import pydymenu # type: ignore
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
# App imports # App imports
from classes import MusicMusterSignals from classes import MusicMusterSignals, TrackFileData
from config import Config
from helpers import ( from helpers import (
ask_yes_no, ask_yes_no,
get_relative_date, get_relative_date,
get_tags,
ms_to_mmss, ms_to_mmss,
show_warning,
) )
from log import log from log import log
from models import Settings, Tracks from models import db, Settings, Tracks
from playlistmodel import PlaylistModel from playlistmodel import PlaylistModel
from ui import dlg_TrackSelect_ui from ui import dlg_TrackSelect_ui, dlg_replace_files_ui
class ReplaceFilesDialog(QDialog):
"""Import files as new or replacements"""
def __init__(
self,
session: Session,
main_window: QMainWindow,
*args,
**kwargs,
) -> None:
super().__init__(*args, **kwargs)
self.session = session
self.main_window = main_window
self.ui = dlg_replace_files_ui.Ui_Dialog()
self.ui.setupUi(self)
self.ui.lblSourceDirectory.setText(Config.REPLACE_FILES_DEFAULT_SOURCE)
self.ui.lblDestinationDirectory.setText(
Config.REPLACE_FILES_DEFAULT_DESTINATION
)
self.replacement_files: list[TrackFileData] = []
# We only want to run this against the production database because
# we will affect files in the common pool of tracks used by all
# databases
dburi = os.environ.get("DATABASE_URL")
if not dburi or "musicmuster_prod" not in dburi:
if not ask_yes_no(
"Not production database",
"Not on production database - continue?",
default_yes=False,
):
return
if self.ui.lblSourceDirectory.text() == self.ui.lblDestinationDirectory.text():
show_warning(
parent=self.main_window,
title="Error",
msg="Cannot import into source directory",
)
return
self.ui.tableWidget.setHorizontalHeaderLabels(["Path", "Title", "Artist"])
# Work through new files
source_dir = self.ui.lblSourceDirectory.text()
with db.Session() as session:
for new_file_basename in os.listdir(source_dir):
new_file_path = os.path.join(source_dir, new_file_basename)
if not os.path.isfile(new_file_path):
continue
rf = TrackFileData(new_file_path=new_file_path)
rf.tags = get_tags(new_file_path)
if not (
"title" in rf.tags
and "artist" in rf.tags
and rf.tags["title"]
and rf.tags["artist"]
):
show_warning(
parent=self.main_window,
title="Error",
msg=(
f"File {new_file_path} missing tags\n\n:"
f"Title={rf.tags['title']}\n"
f"Artist={rf.tags['artist']}\n"
),
)
return
# Check for same filename
match_track = self.check_by_basename(
session, new_file_path, rf.tags["artist"], rf.tags["title"]
)
if not match_track:
match_track = self.check_by_title(
session, new_file_path, rf.tags["artist"], rf.tags["title"]
)
if not match_track:
match_track = self.get_fuzzy_match(session, new_file_basename)
# Build summary
if match_track:
# We will store new file in the same directory as the
# existing file but with the new file name
rf.track_path = os.path.join(
os.path.dirname(match_track.path), new_file_basename
)
# We will remove existing track file
rf.obsolete_path = match_track.path
rf.track_id = match_track.id
match_basename = os.path.basename(match_track.path)
if match_basename == new_file_basename:
path_text = " " + new_file_basename + " (no change)"
else:
path_text = (
f" {match_basename}\n {new_file_basename} (replace)"
)
filename_item = QTableWidgetItem(path_text)
if match_track.title == rf.tags["title"]:
title_text = " " + rf.tags["title"] + " (no change)"
else:
title_text = (
f" {match_track.title}\n {rf.tags['title']} (update)"
)
title_item = QTableWidgetItem(title_text)
if match_track.artist == rf.tags["artist"]:
artist_text = " " + rf.tags["artist"] + " (no change)"
else:
artist_text = (
f" {match_track.artist}\n {rf.tags['artist']} (update)"
)
artist_item = QTableWidgetItem(artist_text)
else:
rf.track_path = os.path.join(
Config.REPLACE_FILES_DEFAULT_DESTINATION, new_file_basename
)
filename_item = QTableWidgetItem(" " + new_file_basename + " (new)")
title_item = QTableWidgetItem(" " + rf.tags["title"])
artist_item = QTableWidgetItem(" " + rf.tags["artist"])
self.replacement_files.append(rf)
row = self.ui.tableWidget.rowCount()
self.ui.tableWidget.insertRow(row)
self.ui.tableWidget.setItem(row, 0, filename_item)
self.ui.tableWidget.setItem(row, 1, title_item)
self.ui.tableWidget.setItem(row, 2, artist_item)
self.ui.tableWidget.resizeColumnsToContents()
self.ui.tableWidget.resizeRowsToContents()
def check_by_basename(
self, session: Session, new_path: str, new_path_artist: str, new_path_title: str
) -> Optional[Tracks]:
"""
Return Track that matches basename and tags
"""
match_track = None
candidates_by_basename = Tracks.get_by_basename(session, new_path)
if candidates_by_basename:
# Check tags are the same
for cbbn in candidates_by_basename:
cbbn_tags = get_tags(cbbn.path)
if (
"title" in cbbn_tags
and cbbn_tags["title"].lower() == new_path_title.lower()
and "artist" in cbbn_tags
and cbbn_tags["artist"].lower() == new_path_artist.lower()
):
match_track = cbbn
break
return match_track
def check_by_title(
self, session: Session, new_path: str, new_path_artist: str, new_path_title: str
) -> Optional[Tracks]:
"""
Return Track that mathces title and artist
"""
match_track = None
candidates_by_title = Tracks.search_titles(session, new_path_title)
if candidates_by_title:
# Check artist tag
for cbt in candidates_by_title:
try:
cbt_artist = get_tags(cbt.path)["artist"]
if cbt_artist.lower() == new_path_artist.lower():
match_track = cbt
break
except FileNotFoundError:
return None
return match_track
def get_fuzzy_match(self, session: Session, fname: str) -> Optional[Tracks]:
"""
Return Track that matches fuzzy filename search
"""
match_track = None
choice = pydymenu.rofi([a.path for a in Tracks.get_all(session)], prompt=fname)
if choice:
match_track = Tracks.get_by_path(session, choice[0])
return match_track
class TrackSelectDialog(QDialog): class TrackSelectDialog(QDialog):
@ -31,22 +232,21 @@ class TrackSelectDialog(QDialog):
def __init__( def __init__(
self, self,
parent: QMainWindow,
session: Session, session: Session,
new_row_number: int, new_row_number: int,
base_model: PlaylistModel, source_model: PlaylistModel,
add_to_header: Optional[bool] = False, add_to_header: Optional[bool] = False,
*args: Qt.WindowType, *args,
**kwargs: Qt.WindowType, **kwargs,
) -> None: ) -> None:
""" """
Subclassed QDialog to manage track selection Subclassed QDialog to manage track selection
""" """
super().__init__(parent, *args, **kwargs) super().__init__(*args, **kwargs)
self.session = session self.session = session
self.new_row_number = new_row_number self.new_row_number = new_row_number
self.base_model = base_model self.source_model = source_model
self.add_to_header = add_to_header self.add_to_header = add_to_header
self.ui = dlg_TrackSelect_ui.Ui_Dialog() self.ui = dlg_TrackSelect_ui.Ui_Dialog()
self.ui.setupUi(self) self.ui.setupUi(self)
@ -90,7 +290,7 @@ class TrackSelectDialog(QDialog):
track_id = track.id track_id = track.id
if note and not track_id: if note and not track_id:
self.base_model.insert_row(self.new_row_number, track_id, note) self.source_model.insert_row(self.new_row_number, track_id, note)
self.ui.txtNote.clear() self.ui.txtNote.clear()
self.new_row_number += 1 self.new_row_number += 1
return return
@ -104,7 +304,7 @@ class TrackSelectDialog(QDialog):
# Check whether track is already in playlist # Check whether track is already in playlist
move_existing = False move_existing = False
existing_prd = self.base_model.is_track_in_playlist(track_id) existing_prd = self.source_model.is_track_in_playlist(track_id)
if existing_prd is not None: if existing_prd is not None:
if ask_yes_no( if ask_yes_no(
"Duplicate row", "Duplicate row",
@ -115,21 +315,21 @@ class TrackSelectDialog(QDialog):
if self.add_to_header: if self.add_to_header:
if move_existing and existing_prd: # "and existing_prd" for mypy's benefit if move_existing and existing_prd: # "and existing_prd" for mypy's benefit
self.base_model.move_track_to_header( self.source_model.move_track_to_header(
self.new_row_number, existing_prd, note self.new_row_number, existing_prd, note
) )
else: else:
self.base_model.add_track_to_header(self.new_row_number, track_id) self.source_model.add_track_to_header(self.new_row_number, track_id)
# Close dialog - we can only add one track to a header # Close dialog - we can only add one track to a header
self.accept() self.accept()
else: else:
# Adding a new track row # Adding a new track row
if move_existing and existing_prd: # "and existing_prd" for mypy's benefit if move_existing and existing_prd: # "and existing_prd" for mypy's benefit
self.base_model.move_track_add_note( self.source_model.move_track_add_note(
self.new_row_number, existing_prd, note self.new_row_number, existing_prd, note
) )
else: else:
self.base_model.insert_row(self.new_row_number, track_id, note) self.source_model.insert_row(self.new_row_number, track_id, note)
self.new_row_number += 1 self.new_row_number += 1

View File

@ -1,777 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass, field
from fuzzywuzzy import fuzz # type: ignore
import os.path
import threading
from typing import Optional, Sequence
import os
import shutil
# PyQt imports
from PyQt6.QtCore import (
pyqtSignal,
QThread,
)
from PyQt6.QtWidgets import (
QButtonGroup,
QDialog,
QFileDialog,
QHBoxLayout,
QLabel,
QPushButton,
QRadioButton,
QVBoxLayout,
)
# Third party imports
# App imports
from classes import (
ApplicationError,
MusicMusterSignals,
singleton,
Tags,
)
from config import Config
from helpers import (
audio_file_extension,
file_is_unreadable,
get_tags,
show_OK,
)
from log import log
from models import db, Tracks
from music_manager import track_sequence
from playlistmodel import PlaylistModel
import helpers
@dataclass
class ThreadData:
"""
Data structure to hold details of the import thread context
"""
base_model: PlaylistModel
row_number: int
@dataclass
class TrackFileData:
"""
Data structure to hold details of file to be imported
"""
source_path: str
tags: Tags = Tags()
destination_path: str = ""
import_this_file: bool = False
error: str = ""
file_path_to_remove: Optional[str] = None
track_id: int = 0
track_match_data: list[TrackMatchData] = field(default_factory=list)
@dataclass
class TrackMatchData:
"""
Data structure to hold details of existing files that are similar to
the file being imported.
"""
artist: str
artist_match: float
title: str
title_match: float
track_id: int
@singleton
class FileImporter:
"""
Class to manage the import of new tracks. Sanity checks are carried
out before processing each track.
They may replace existing tracks, be imported as new tracks, or the
import may be skipped altogether. The user decides which of these in
the UI managed by the PickMatch class.
The actual import is handled by the DoTrackImport class.
"""
# Place to keep a reference to importer workers. This is an instance
# variable to allow tests access. As this is a singleton, a class
# variable or an instance variable are effectively the same thing.
workers: dict[str, DoTrackImport] = {}
def __init__(self, base_model: PlaylistModel, row_number: int) -> None:
"""
Initialise the FileImporter singleton instance.
"""
log.debug(f"FileImporter.__init__({base_model=}, {row_number=})")
# Create ModelData
self.model_data = ThreadData(base_model=base_model, row_number=row_number)
# Data structure to track files to import
self.import_files_data: list[TrackFileData] = []
# Get signals
self.signals = MusicMusterSignals()
def _get_existing_tracks(self) -> Sequence[Tracks]:
"""
Return a list of all existing Tracks
"""
with db.Session() as session:
return Tracks.get_all(session)
def start(self) -> None:
"""
Build a TrackFileData object for each new file to import, add it
to self.import_files_data, and trigger importing.
"""
new_files: list[str] = []
if not os.listdir(Config.REPLACE_FILES_DEFAULT_SOURCE):
show_OK(
"File import",
f"No files in {Config.REPLACE_FILES_DEFAULT_SOURCE} to import",
None,
)
return
# Refresh list of existing tracks as they may have been updated
# by previous imports
self.existing_tracks = self._get_existing_tracks()
for infile in [
os.path.join(Config.REPLACE_FILES_DEFAULT_SOURCE, f)
for f in os.listdir(Config.REPLACE_FILES_DEFAULT_SOURCE)
if f.endswith((".mp3", ".flac"))
]:
if infile in [a.source_path for a in self.import_files_data]:
log.debug(f"file_importer.start skipping {infile=}, already queued")
else:
new_files.append(infile)
self.import_files_data.append(self.populate_trackfiledata(infile))
# Tell user which files won't be imported and why
self.inform_user(
[
a
for a in self.import_files_data
if a.source_path in new_files and a.import_this_file is False
]
)
# Remove do-not-import entries from queue
self.import_files_data[:] = [
a for a in self.import_files_data if a.import_this_file is not False
]
# Start the import if necessary
log.debug(
f"Import files prepared: {[a.source_path for a in self.import_files_data]}"
)
self._import_next_file()
def populate_trackfiledata(self, path: str) -> TrackFileData:
"""
Populate TrackFileData object for path:
- Validate file to be imported
- Find matches and similar files
- Get user choices for each import file
- Validate self.import_files_data integrity
- Tell the user which files won't be imported and why
- Import the files, one by one.
"""
tfd = TrackFileData(source_path=path)
if self.check_file_readable(tfd):
if self.check_file_tags(tfd):
self.find_similar(tfd)
if len(tfd.track_match_data) > 1:
self.sort_track_match_data(tfd)
selection = self.get_user_choices(tfd)
if self.process_selection(tfd, selection):
if self.extension_check(tfd):
if self.validate_file_data(tfd):
tfd.import_this_file = True
return tfd
def check_file_readable(self, tfd: TrackFileData) -> bool:
"""
Check file is readable.
Return True if it is.
Populate error and return False if not.
"""
if file_is_unreadable(tfd.source_path):
tfd.import_this_file = False
tfd.error = f"{tfd.source_path} is unreadable"
return False
return True
def check_file_tags(self, tfd: TrackFileData) -> bool:
"""
Add tags to tfd
Return True if successful.
Populate error and return False if not.
"""
try:
tfd.tags = get_tags(tfd.source_path)
except ApplicationError as e:
tfd.import_this_file = False
tfd.error = f"of tag errors ({str(e)})"
return False
return True
def extension_check(self, tfd: TrackFileData) -> bool:
"""
If we are replacing an existing file, check that the correct file
extension of the replacement file matches the existing file
extension and return True if it does (or if there is no exsting
file), else False.
"""
if not tfd.file_path_to_remove:
return True
if tfd.file_path_to_remove.endswith(audio_file_extension(tfd.source_path)):
return True
tfd.error = (
f"Existing file ({tfd.file_path_to_remove}) has a different "
f"extension to replacement file ({tfd.source_path})"
)
return False
def find_similar(self, tfd: TrackFileData) -> None:
"""
- Search title in existing tracks
- if score >= Config.FUZZYMATCH_MINIMUM_LIST:
- get artist score
- add TrackMatchData to self.import_files_data[path].track_match_data
"""
title = tfd.tags.title
artist = tfd.tags.artist
for existing_track in self.existing_tracks:
title_score = self._get_match_score(title, existing_track.title)
if title_score >= Config.FUZZYMATCH_MINIMUM_LIST:
artist_score = self._get_match_score(artist, existing_track.artist)
tfd.track_match_data.append(
TrackMatchData(
artist=existing_track.artist,
artist_match=artist_score,
title=existing_track.title,
title_match=title_score,
track_id=existing_track.id,
)
)
def sort_track_match_data(self, tfd: TrackFileData) -> None:
"""
Sort matched tracks in artist-similarity order
"""
tfd.track_match_data.sort(key=lambda x: x.artist_match, reverse=True)
def _get_match_score(self, str1: str, str2: str) -> float:
"""
Return the score of how well str1 matches str2.
"""
ratio = fuzz.ratio(str1, str2)
partial_ratio = fuzz.partial_ratio(str1, str2)
token_sort_ratio = fuzz.token_sort_ratio(str1, str2)
token_set_ratio = fuzz.token_set_ratio(str1, str2)
# Combine scores
combined_score = (
ratio * 0.25
+ partial_ratio * 0.25
+ token_sort_ratio * 0.25
+ token_set_ratio * 0.25
)
return combined_score
def get_user_choices(self, tfd: TrackFileData) -> int:
"""
Find out whether user wants to import this as a new track,
overwrite an existing track or not import it at all.
Return -1 (user cancelled) 0 (import as new) >0 (replace track id)
"""
# Build a list of (track title and artist, track_id, track path)
choices: list[tuple[str, int, str]] = []
# First choices are always a) don't import 2) import as a new track
choices.append((Config.DO_NOT_IMPORT, -1, ""))
choices.append((Config.IMPORT_AS_NEW, 0, ""))
# New track details
new_track_description = f"{tfd.tags.title} ({tfd.tags.artist})"
# Select 'import as new' as default unless the top match is good
# enough
default = 1
track_match_data = tfd.track_match_data
if track_match_data:
if (
track_match_data[0].artist_match
>= Config.FUZZYMATCH_MINIMUM_SELECT_ARTIST
and track_match_data[0].title_match
>= Config.FUZZYMATCH_MINIMUM_SELECT_TITLE
):
default = 2
for xt in track_match_data:
xt_description = f"{xt.title} ({xt.artist})"
if Config.FUZZYMATCH_SHOW_SCORES:
xt_description += f" ({xt.title_match:.0f}%)"
existing_track_path = self._get_existing_track(xt.track_id).path
choices.append(
(
xt_description,
xt.track_id,
existing_track_path,
)
)
dialog = PickMatch(
new_track_description=new_track_description,
choices=choices,
default=default,
)
if dialog.exec():
return dialog.selected_track_id
else:
return -1
def process_selection(self, tfd: TrackFileData, selection: int) -> bool:
"""
Process selection from PickMatch
"""
if selection < 0:
# User cancelled
tfd.import_this_file = False
tfd.error = "you asked not to import this file"
return False
elif selection > 0:
# Import and replace track
self.replace_file(tfd, track_id=selection)
else:
# Import as new
self.import_as_new(tfd)
return True
def replace_file(self, tfd: TrackFileData, track_id: int) -> None:
"""
Set up to replace an existing file.
"""
log.debug(f"replace_file({tfd=}, {track_id=})")
if track_id < 1:
raise ApplicationError(f"No track ID: replace_file({tfd=}, {track_id=})")
tfd.track_id = track_id
existing_track_path = self._get_existing_track(track_id).path
tfd.file_path_to_remove = existing_track_path
# If the existing file in the Config.IMPORT_DESTINATION
# directory, replace it with the imported file name; otherwise,
# use the existing file name. This so that we don't change file
# names from CDs, etc.
if os.path.dirname(existing_track_path) == Config.IMPORT_DESTINATION:
tfd.destination_path = os.path.join(
Config.IMPORT_DESTINATION, os.path.basename(tfd.source_path)
)
else:
tfd.destination_path = existing_track_path
def _get_existing_track(self, track_id: int) -> Tracks:
"""
Lookup in existing track in the local cache and return it
"""
existing_track_records = [a for a in self.existing_tracks if a.id == track_id]
if len(existing_track_records) != 1:
raise ApplicationError(
f"Internal error in _get_existing_track: {existing_track_records=}"
)
return existing_track_records[0]
def import_as_new(self, tfd: TrackFileData) -> None:
"""
Set up to import as a new file.
"""
tfd.destination_path = os.path.join(
Config.IMPORT_DESTINATION, os.path.basename(tfd.source_path)
)
def validate_file_data(self, tfd: TrackFileData) -> bool:
"""
Check the data structures for integrity
Return True if all OK
Populate error and return False if not.
"""
# Check tags
if not (tfd.tags.artist and tfd.tags.title):
raise ApplicationError(
f"validate_file_data: {tfd.tags=}, {tfd.source_path=}"
)
# Check file_path_to_remove
if tfd.file_path_to_remove and not os.path.exists(tfd.file_path_to_remove):
# File to remove is missing, but this isn't a major error. We
# may be importing to replace a deleted file.
tfd.file_path_to_remove = ""
# Check destination_path
if not tfd.destination_path:
raise ApplicationError(
f"validate_file_data: no destination path set ({tfd.source_path=})"
)
# If destination path is the same as file_path_to_remove, that's
# OK, otherwise if this is a new import then check that
# destination path doesn't already exists
if tfd.track_id == 0 and tfd.destination_path != tfd.file_path_to_remove:
while os.path.exists(tfd.destination_path):
msg = (
"New import requested but default destination path"
f" ({tfd.destination_path})"
" already exists. Click OK and choose where to save this track"
)
show_OK(title="Desintation path exists", msg=msg, parent=None)
# Get output filename
pathspec = QFileDialog.getSaveFileName(
None,
"Save imported track",
directory=Config.IMPORT_DESTINATION,
)
if pathspec:
if pathspec == "":
# User cancelled
tfd.error = "You did not select a location to save this track"
return False
tfd.destination_path = pathspec[0]
else:
tfd.error = "destination file already exists"
return False
# The desintation path should not already exist in the
# database (becquse if it does, it points to a non-existent
# file). Check that because the path field in the database is
# unique and so adding a duplicate will give a db integrity
# error.
with db.Session() as session:
if Tracks.get_by_path(session, tfd.destination_path):
tfd.error = (
"Importing a new track but destination path already exists "
f"in database ({tfd.destination_path})"
)
return False
# Check track_id
if tfd.track_id < 0:
raise ApplicationError(
f"validate_file_data: track_id < 0, {tfd.source_path=}"
)
return True
def inform_user(self, tfds: list[TrackFileData]) -> None:
"""
Tell user about files that won't be imported
"""
msgs: list[str] = []
for tfd in tfds:
msgs.append(
f"{os.path.basename(tfd.source_path)} will not be imported because {tfd.error}"
)
if msgs:
show_OK("File not imported", "\r\r".join(msgs))
log.debug("\r\r".join(msgs))
def _import_next_file(self) -> None:
"""
Import the next file sequentially.
This is called when an import completes so will be called asynchronously.
Protect with a lock.
"""
lock = threading.Lock()
with lock:
while len(self.workers) < Config.MAX_IMPORT_THREADS:
try:
tfd = self.import_files_data.pop()
filename = os.path.basename(tfd.source_path)
log.debug(f"Processing {filename}")
log.debug(
f"remaining files: {[a.source_path for a in self.import_files_data]}"
)
self.signals.status_message_signal.emit(
f"Importing {filename}", 10000
)
self._start_import(tfd)
except IndexError:
log.debug("import_next_file: no files remaining in queue")
break
def _start_import(self, tfd: TrackFileData) -> None:
"""
Start thread to import track
"""
filename = os.path.basename(tfd.source_path)
log.debug(f"_start_import({filename=})")
self.workers[tfd.source_path] = DoTrackImport(
import_file_path=tfd.source_path,
tags=tfd.tags,
destination_path=tfd.destination_path,
track_id=tfd.track_id,
file_path_to_remove=tfd.file_path_to_remove,
)
log.debug(f"{self.workers[tfd.source_path]=} created")
self.workers[tfd.source_path].import_finished.connect(
self.post_import_processing
)
self.workers[tfd.source_path].finished.connect(lambda: self.cleanup_thread(tfd))
self.workers[tfd.source_path].finished.connect(
self.workers[tfd.source_path].deleteLater
)
self.workers[tfd.source_path].start()
def cleanup_thread(self, tfd: TrackFileData) -> None:
"""
Remove references to finished threads/workers to prevent leaks.
"""
log.debug(f"cleanup_thread({tfd.source_path=})")
if tfd.source_path in self.workers:
del self.workers[tfd.source_path]
else:
log.error(f"Couldn't find {tfd.source_path=} in {self.workers.keys()=}")
log.debug(f"After cleanup_thread: {self.workers.keys()=}")
def post_import_processing(self, source_path: str, track_id: int) -> None:
"""
If track already in playlist, refresh it else insert it
"""
log.debug(f"post_import_processing({source_path=}, {track_id=})")
if self.model_data:
if self.model_data.base_model:
self.model_data.base_model.update_or_insert(
track_id, self.model_data.row_number
)
# Process next file(s)
self._import_next_file()
class DoTrackImport(QThread):
"""
Class to manage the actual import of tracks in a thread.
"""
import_finished = pyqtSignal(str, int)
def __init__(
self,
import_file_path: str,
tags: Tags,
destination_path: str,
track_id: int,
file_path_to_remove: Optional[str] = None,
) -> None:
"""
Save parameters
"""
super().__init__()
self.import_file_path = import_file_path
self.tags = tags
self.destination_track_path = destination_path
self.track_id = track_id
self.file_path_to_remove = file_path_to_remove
self.signals = MusicMusterSignals()
def __repr__(self) -> str:
return f"<DoTrackImport(id={hex(id(self))}, import_file_path={self.import_file_path}"
def run(self) -> None:
"""
Either create track objects from passed files or update exising track
objects.
And add to visible playlist or update playlist if track already present.
"""
self.signals.status_message_signal.emit(
f"Importing {os.path.basename(self.import_file_path)}", 5000
)
# Get audio metadata in this thread rather than calling
# function to save interactive time
self.audio_metadata = helpers.get_audio_metadata(self.import_file_path)
# Remove old file if so requested
if self.file_path_to_remove and os.path.exists(self.file_path_to_remove):
os.unlink(self.file_path_to_remove)
# Move new file to destination
shutil.move(self.import_file_path, self.destination_track_path)
with db.Session() as session:
if self.track_id == 0:
# Import new track
try:
track = Tracks(
session,
path=self.destination_track_path,
**self.tags._asdict(),
**self.audio_metadata._asdict(),
)
except Exception as e:
self.signals.show_warning_signal.emit(
"Error importing track", str(e)
)
return
else:
track = session.get(Tracks, self.track_id)
if track:
for key, value in self.tags._asdict().items():
if hasattr(track, key):
setattr(track, key, value)
for key, value in self.audio_metadata._asdict().items():
if hasattr(track, key):
setattr(track, key, value)
track.path = self.destination_track_path
else:
log.error(f"Unable to retrieve {self.track_id=}")
return
session.commit()
helpers.normalise_track(self.destination_track_path)
self.signals.status_message_signal.emit(
f"{os.path.basename(self.import_file_path)} imported", 10000
)
self.import_finished.emit(self.import_file_path, track.id)
class PickMatch(QDialog):
"""
Dialog for user to select which existing track to replace or to
import to a new track
"""
def __init__(
self,
new_track_description: str,
choices: list[tuple[str, int, str]],
default: int,
) -> None:
super().__init__()
self.new_track_description = new_track_description
self.default = default
self.init_ui(choices)
self.selected_track_id = -1
def init_ui(self, choices: list[tuple[str, int, str]]) -> None:
"""
Set up dialog
"""
self.setWindowTitle("New or replace")
layout = QVBoxLayout()
# Add instructions
instructions = (
f"Importing {self.new_track_description}.\n"
"Import as a new track or replace existing track?"
)
instructions_label = QLabel(instructions)
layout.addWidget(instructions_label)
# Create a button group for radio buttons
self.button_group = QButtonGroup()
# Add radio buttons for each item
for idx, (track_description, track_id, track_path) in enumerate(choices):
if (
track_sequence.current
and track_id
and track_sequence.current.track_id == track_id
):
# Don't allow current track to be replaced
track_description = "(Currently playing) " + track_description
radio_button = QRadioButton(track_description)
radio_button.setDisabled(True)
self.button_group.addButton(radio_button, -1)
else:
radio_button = QRadioButton(track_description)
radio_button.setToolTip(track_path)
self.button_group.addButton(radio_button, track_id)
layout.addWidget(radio_button)
# Select the second item by default (import as new)
if idx == self.default:
radio_button.setChecked(True)
# Add OK and Cancel buttons
button_layout = QHBoxLayout()
ok_button = QPushButton("OK")
cancel_button = QPushButton("Cancel")
button_layout.addWidget(ok_button)
button_layout.addWidget(cancel_button)
layout.addLayout(button_layout)
self.setLayout(layout)
# Connect buttons to actions
ok_button.clicked.connect(self.on_ok)
cancel_button.clicked.connect(self.reject)
def on_ok(self):
# Get the ID of the selected button
self.selected_track_id = self.button_group.checkedId()
self.accept()

View File

@ -1,7 +1,8 @@
# Standard library imports # Standard library imports
import datetime as dt import datetime as dt
from email.message import EmailMessage from email.message import EmailMessage
from typing import Optional from typing import Any, Dict, Optional
import functools
import os import os
import re import re
import shutil import shutil
@ -10,18 +11,16 @@ import ssl
import tempfile import tempfile
# PyQt imports # PyQt imports
from PyQt6.QtWidgets import QInputDialog, QMainWindow, QMessageBox, QWidget from PyQt6.QtWidgets import QMainWindow, QMessageBox
# Third party imports # Third party imports
import filetype
from mutagen.flac import FLAC # type: ignore from mutagen.flac import FLAC # type: ignore
from mutagen.mp3 import MP3 # type: ignore from mutagen.mp3 import MP3 # type: ignore
from pydub import AudioSegment, effects from pydub import AudioSegment, effects
from pydub.utils import mediainfo from pydub.utils import mediainfo
from tinytag import TinyTag, TinyTagException # type: ignore from tinytag import TinyTag # type: ignore
# App imports # App imports
from classes import AudioMetadata, ApplicationError, Tags
from config import Config from config import Config
from log import log from log import log
from models import Tracks from models import Tracks
@ -51,14 +50,6 @@ def ask_yes_no(
return button == QMessageBox.StandardButton.Yes return button == QMessageBox.StandardButton.Yes
def audio_file_extension(fpath: str) -> str | None:
"""
Return the correct extension for this type of file.
"""
return filetype.guess(fpath).extension
def fade_point( def fade_point(
audio_segment: AudioSegment, audio_segment: AudioSegment,
fade_threshold: float = 0.0, fade_threshold: float = 0.0,
@ -103,9 +94,6 @@ def file_is_unreadable(path: Optional[str]) -> bool:
def get_audio_segment(path: str) -> Optional[AudioSegment]: def get_audio_segment(path: str) -> Optional[AudioSegment]:
if not path.endswith(audio_file_extension(path)):
return None
try: try:
if path.endswith(".mp3"): if path.endswith(".mp3"):
return AudioSegment.from_mp3(path) return AudioSegment.from_mp3(path)
@ -133,25 +121,25 @@ def get_embedded_time(text: str) -> Optional[dt.datetime]:
return None return None
def get_all_track_metadata(filepath: str) -> dict[str, str | int | float]: def get_all_track_metadata(filepath: str) -> Dict[str, str | int | float]:
"""Return all track metadata""" """Return all track metadata"""
return ( return get_audio_metadata(filepath) | get_tags(filepath) | dict(path=filepath)
get_audio_metadata(filepath)._asdict()
| get_tags(filepath)._asdict()
| dict(path=filepath)
)
def get_audio_metadata(filepath: str) -> AudioMetadata: def get_audio_metadata(filepath: str) -> Dict[str, str | int | float]:
"""Return audio metadata""" """Return audio metadata"""
metadata: Dict[str, str | int | float] = {}
metadata["mtime"] = os.path.getmtime(filepath)
# Set start_gap, fade_at and silence_at # Set start_gap, fade_at and silence_at
audio = get_audio_segment(filepath) audio = get_audio_segment(filepath)
if not audio: if not audio:
return AudioMetadata() audio_values = dict(start_gap=0, fade_at=0, silence_at=0)
else: else:
return AudioMetadata( audio_values = dict(
start_gap=leading_silence(audio), start_gap=leading_silence(audio),
fade_at=int( fade_at=int(
round(fade_point(audio) / 1000, Config.MILLISECOND_SIGFIGS) * 1000 round(fade_point(audio) / 1000, Config.MILLISECOND_SIGFIGS) * 1000
@ -160,23 +148,9 @@ def get_audio_metadata(filepath: str) -> AudioMetadata:
round(trailing_silence(audio) / 1000, Config.MILLISECOND_SIGFIGS) * 1000 round(trailing_silence(audio) / 1000, Config.MILLISECOND_SIGFIGS) * 1000
), ),
) )
metadata |= audio_values
return metadata
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(
@ -218,30 +192,17 @@ def get_relative_date(
days_str = "day" days_str = "day"
else: else:
days_str = "days" days_str = "days"
return f"{weeks} {weeks_str}, {days} {days_str}" return f"{weeks} {weeks_str}, {days} {days_str} ago"
def get_tags(path: str) -> Tags: def get_tags(path: str) -> Dict[str, Any]:
""" """
Return a dictionary of title, artist, bitrate and duration-in-milliseconds. Return a dictionary of title, artist, duration-in-milliseconds and path.
""" """
try:
tag = TinyTag.get(path) tag = TinyTag.get(path)
except FileNotFoundError:
raise ApplicationError(f"File not found: {path}")
except TinyTagException:
raise ApplicationError(f"Can't read tags in {path}")
if ( return dict(
tag.title is None
or tag.artist is None
or tag.bitrate is None
or tag.duration is None
):
raise ApplicationError(f"Missing tags in {path}")
return Tags(
title=tag.title, title=tag.title,
artist=tag.artist, artist=tag.artist,
bitrate=round(tag.bitrate), bitrate=round(tag.bitrate),
@ -317,7 +278,7 @@ def normalise_track(path: str) -> None:
# Check type # Check type
ftype = os.path.splitext(path)[1][1:] ftype = os.path.splitext(path)[1][1:]
if ftype not in ["mp3", "flac"]: if ftype not in ["mp3", "flac"]:
log.error( log.info(
f"helpers.normalise_track({path}): " f"File type {ftype} not implemented" f"helpers.normalise_track({path}): " f"File type {ftype} not implemented"
) )
@ -365,32 +326,6 @@ def normalise_track(path: str) -> None:
os.remove(temp_path) os.remove(temp_path)
def remove_substring_case_insensitive(parent_string: str, substring: str) -> str:
"""
Remove all instances of substring from parent string, case insensitively
"""
# Convert both strings to lowercase for case-insensitive comparison
lower_parent = parent_string.lower()
lower_substring = substring.lower()
# Initialize the result string
result = parent_string
# Continue removing the substring until it's no longer found
while lower_substring in lower_parent:
# Find the index of the substring
index = lower_parent.find(lower_substring)
# Remove the substring
result = result[:index] + result[index + len(substring) :]
# Update the lowercase versions
lower_parent = result.lower()
return result
def send_mail(to_addr: str, from_addr: str, subj: str, body: str) -> None: def send_mail(to_addr: str, from_addr: str, subj: str, body: str) -> None:
# From https://docs.python.org/3/library/email.examples.html # From https://docs.python.org/3/library/email.examples.html
@ -423,30 +358,40 @@ def set_track_metadata(track: Tracks) -> None:
audio_metadata = get_audio_metadata(track.path) audio_metadata = get_audio_metadata(track.path)
tags = get_tags(track.path) tags = get_tags(track.path)
for audio_key in AudioMetadata._fields: for audio_key in audio_metadata:
setattr(track, audio_key, getattr(audio_metadata, audio_key)) setattr(track, audio_key, audio_metadata[audio_key])
for tag_key in Tags._fields: for tag_key in tags:
setattr(track, tag_key, getattr(tags, tag_key)) setattr(track, tag_key, tags[tag_key])
def show_OK(title: str, msg: str, parent: Optional[QWidget] = None) -> None: def show_OK(parent: QMainWindow, title: str, msg: str) -> None:
"""Display a message to user""" """Display a message to user"""
dlg = QMessageBox(parent) QMessageBox.information(parent, title, msg, buttons=QMessageBox.StandardButton.Ok)
dlg.setIcon(QMessageBox.Icon.Information)
dlg.setWindowTitle(title)
dlg.setText(msg)
dlg.setStandardButtons(QMessageBox.StandardButton.Ok)
_ = dlg.exec()
def show_warning(parent: Optional[QMainWindow], title: str, msg: str) -> None: def show_warning(parent: QMainWindow, title: str, msg: str) -> None:
"""Display a warning to user""" """Display a warning to user"""
QMessageBox.warning(parent, title, msg, buttons=QMessageBox.StandardButton.Cancel) QMessageBox.warning(parent, title, msg, buttons=QMessageBox.StandardButton.Cancel)
def singleton(cls):
"""
Make a class a Singleton class (see
https://realpython.com/primer-on-python-decorators/#creating-singletons)
"""
@functools.wraps(cls)
def wrapper_singleton(*args, **kwargs):
if not wrapper_singleton.instance:
wrapper_singleton.instance = cls(*args, **kwargs)
return wrapper_singleton.instance
wrapper_singleton.instance = None
return wrapper_singleton
def trailing_silence( def trailing_silence(
audio_segment: AudioSegment, audio_segment: AudioSegment,
silence_threshold: int = -50, silence_threshold: int = -50,

92
app/infotabs.py Normal file
View File

@ -0,0 +1,92 @@
# Standard library imports
import urllib.parse
import datetime as dt
from slugify import slugify # type: ignore
from typing import Dict
# PyQt imports
from PyQt6.QtCore import QUrl # type: ignore
from PyQt6.QtWebEngineWidgets import QWebEngineView
from PyQt6.QtWidgets import QTabWidget
# Third party imports
# App imports
from config import Config
from classes import MusicMusterSignals
from log import log
class InfoTabs(QTabWidget):
"""
Class to manage info tabs
"""
def __init__(self, parent=None) -> None:
super().__init__(parent)
self.signals = MusicMusterSignals()
self.signals.search_songfacts_signal.connect(self.open_in_songfacts)
self.signals.search_wikipedia_signal.connect(self.open_in_wikipedia)
# re-use the oldest one later)
self.last_update: Dict[QWebEngineView, dt.datetime] = {}
self.tabtitles: Dict[int, str] = {}
# Create one tab which (for some reason) creates flickering if
# done later
widget = QWebEngineView()
widget.setZoomFactor(Config.WEB_ZOOM_FACTOR)
self.last_update[widget] = dt.datetime.now()
_ = self.addTab(widget, "")
def open_in_songfacts(self, title: str) -> None:
"""Search Songfacts for title"""
slug = slugify(title, replacements=([["'", ""]]))
log.info(f"Songfacts Infotab for {title=}")
url = f"https://www.songfacts.com/search/songs/{slug}"
self.open_tab(url, title)
def open_in_wikipedia(self, title: str) -> None:
"""Search Wikipedia for title"""
str = urllib.parse.quote_plus(title)
log.info(f"Wikipedia Infotab for {title=}")
url = f"https://www.wikipedia.org/w/index.php?search={str}"
self.open_tab(url, title)
def open_tab(self, url: str, title: str) -> None:
"""
Open passed URL. If URL currently displayed, switch to that tab.
Create new tab if we're below the maximum
number otherwise reuse oldest content tab.
"""
if url in self.tabtitles.values():
self.setCurrentIndex(
list(self.tabtitles.keys())[list(self.tabtitles.values()).index(url)]
)
return
short_title = title[: Config.INFO_TAB_TITLE_LENGTH]
if self.count() < Config.MAX_INFO_TABS:
# Create a new tab
widget = QWebEngineView()
widget.setZoomFactor(Config.WEB_ZOOM_FACTOR)
tab_index = self.addTab(widget, short_title)
else:
# Reuse oldest widget
widget = min(self.last_update, key=self.last_update.get) # type: ignore
tab_index = self.indexOf(widget)
self.setTabText(tab_index, short_title)
widget.setUrl(QUrl(url))
self.last_update[widget] = dt.datetime.now()
self.tabtitles[tab_index] = url
# Show newly updated tab
self.setCurrentIndex(tab_index)

View File

@ -1,56 +0,0 @@
from PyQt6.QtCore import QObject, QTimer, QElapsedTimer
import logging
import time
from config import Config
class EventLoopJitterMonitor(QObject):
def __init__(
self,
parent=None,
interval_ms: int = 20,
jitter_threshold_ms: int = 100,
log_cooldown_s: float = 1.0,
):
super().__init__(parent)
self._interval = interval_ms
self._jitter_threshold = jitter_threshold_ms
self._log_cooldown_s = log_cooldown_s
self._timer = QTimer(self)
self._timer.setInterval(self._interval)
self._timer.timeout.connect(self._on_timeout)
self._elapsed = QElapsedTimer()
self._elapsed.start()
self._last = self._elapsed.elapsed()
# child logger: e.g. "musicmuster.jitter"
self._log = logging.getLogger(f"{Config.LOG_NAME}.jitter")
self._last_log_time = 0.0
def start(self) -> None:
self._timer.start()
def _on_timeout(self) -> None:
now_ms = self._elapsed.elapsed()
delta = now_ms - self._last
self._last = now_ms
if delta > (self._interval + self._jitter_threshold):
self._log_jitter(now_ms, delta)
def _log_jitter(self, now_ms: int, gap_ms: int) -> None:
now = time.monotonic()
# simple rate limit: only one log every log_cooldown_s
if now - self._last_log_time < self._log_cooldown_s:
return
self._last_log_time = now
self._log.warning(
"Event loop gap detected: t=%d ms, gap=%d ms (interval=%d ms)",
now_ms,
gap_ms,
self._interval,
)

View File

@ -1,138 +1,74 @@
#!/usr/bin/env python3 #!/usr/bin/python3
# Standard library imports # Standard library imports
from collections import defaultdict
from functools import wraps
import logging import logging
import logging.config
import logging.handlers import logging.handlers
import os import os
import sys import sys
import traceback from traceback import print_exception
import yaml
# PyQt imports # PyQt imports
from PyQt6.QtWidgets import QApplication, QMessageBox
# Third party imports # Third party imports
import colorlog
import stackprinter # type: ignore import stackprinter # type: ignore
# App imports # App imports
from config import Config from config import Config
from classes import ApplicationError
class FunctionFilter(logging.Filter):
"""Filter to allow category-based logging to stderr."""
def __init__(self, module_functions: dict[str, list[str]]):
super().__init__()
self.modules: list[str] = []
self.functions: defaultdict[str, list[str]] = defaultdict(list)
if module_functions:
for module in module_functions.keys():
if module_functions[module]:
for function in module_functions[module]:
self.functions[module].append(function)
else:
self.modules.append(module)
def filter(self, record: logging.LogRecord) -> bool:
if not getattr(record, "levelname", None) == "DEBUG":
# Only prcess DEBUG messages
return False
module = getattr(record, "module", None)
if not module:
# No module in record
return False
# Process if this is a module we're tracking
if module in self.modules:
return True
# Process if this is a function we're tracking
if getattr(record, "funcName", None) in self.functions[module]:
return True
return False
class LevelTagFilter(logging.Filter): class LevelTagFilter(logging.Filter):
"""Add leveltag""" """Add leveltag"""
def filter(self, record: logging.LogRecord) -> bool: def filter(self, record: logging.LogRecord):
# Extract the first character of the level name # Extract the first character of the level name
record.leveltag = record.levelname[0] record.leveltag = record.levelname[0]
# We never actually filter messages out, just add an extra field
# to the LogRecord # We never actually filter messages out, just abuse filtering to add an
# extra field to the LogRecord
return True return True
# Load YAML logging configuration
with open("app/logging.yaml", "r") as f:
config = yaml.safe_load(f)
logging.config.dictConfig(config)
# Get logger
log = logging.getLogger(Config.LOG_NAME) log = logging.getLogger(Config.LOG_NAME)
log.setLevel(logging.DEBUG)
local_filter = LevelTagFilter()
# stderr
stderr = colorlog.StreamHandler()
stderr.setLevel(Config.LOG_LEVEL_STDERR)
stderr.addFilter(local_filter)
stderr_fmt = colorlog.ColoredFormatter(
"%(log_color)s[%(asctime)s] %(filename)s:%(lineno)s %(message)s", datefmt="%H:%M:%S"
)
stderr.setFormatter(stderr_fmt)
log.addHandler(stderr)
# syslog
syslog = logging.handlers.SysLogHandler(address="/dev/log")
syslog.setLevel(Config.LOG_LEVEL_SYSLOG)
syslog.addFilter(local_filter)
syslog_fmt = logging.Formatter(
"[%(name)s] %(filename)s:%(lineno)s %(leveltag)s: %(message)s"
)
syslog.setFormatter(syslog_fmt)
log.addHandler(syslog)
def handle_exception(exc_type, exc_value, exc_traceback): def log_uncaught_exceptions(type_, value, traceback):
error = str(exc_value)
if issubclass(exc_type, ApplicationError):
log.error(error)
else:
# Handle unexpected errors (log and display)
error_msg = "".join(traceback.format_exception(exc_type, exc_value, exc_traceback))
print(stackprinter.format(exc_value, suppressed_paths=['/.venv'], style='darkbg'))
msg = stackprinter.format(exc_value)
log.error(msg)
log.error(error_msg)
print("Critical error:", error_msg) # Consider logging instead of print
if os.environ["MM_ENV"] == "PRODUCTION":
from helpers import send_mail from helpers import send_mail
send_mail( print("\033[1;31;47m")
Config.ERRORS_TO, print_exception(type_, value, traceback)
Config.ERRORS_FROM, print("\033[1;37;40m")
"Exception (log_uncaught_exceptions) from musicmuster", print(
msg, stackprinter.format(
value, suppressed_paths=["/pypoetry/virtualenvs/"], style="darkbg"
)
)
if os.environ["MM_ENV"] == "PRODUCTION":
msg = stackprinter.format(value)
send_mail(
Config.ERRORS_TO, Config.ERRORS_FROM, "Exception from musicmuster", msg
) )
if QApplication.instance() is not None:
fname = os.path.split(exc_traceback.tb_frame.f_code.co_filename)[1]
msg = f"ApplicationError: {error}\nat {fname}:{exc_traceback.tb_lineno}"
QMessageBox.critical(None, "Application Error", msg)
def truncate_large(obj, limit=5): sys.excepthook = log_uncaught_exceptions
"""Helper to truncate large lists or other iterables."""
if isinstance(obj, (list, tuple, set)):
if len(obj) > limit:
return f"{type(obj).__name__}(len={len(obj)}, items={list(obj)[:limit]}...)"
return repr(obj)
def log_call(func):
@wraps(func)
def wrapper(*args, **kwargs):
args_repr = [truncate_large(a) for a in args]
kwargs_repr = [f"{k}={truncate_large(v)}" for k, v in kwargs.items()]
params_repr = ", ".join(args_repr + kwargs_repr)
log.debug(f"call {func.__name__}({params_repr})")
try:
result = func(*args, **kwargs)
log.debug(f"return {func.__name__}: {truncate_large(result)}")
return result
except Exception as e:
log.debug(f"exception in {func.__name__}: {e}")
raise
return wrapper
sys.excepthook = handle_exception

View File

@ -1,55 +0,0 @@
version: 1
disable_existing_loggers: True
formatters:
colored:
(): colorlog.ColoredFormatter
format: "%(log_color)s[%(asctime)s] %(filename)s.%(funcName)s:%(lineno)s %(blue)s%(message)s"
datefmt: "%H:%M:%S"
syslog:
format: "[%(name)s] %(filename)s:%(lineno)s %(leveltag)s: %(message)s"
filters:
leveltag:
(): log.LevelTagFilter
category_filter:
(): log.FunctionFilter
module_functions:
# Optionally additionally log some debug calls to stderr
# log all debug calls in a module:
# module-name: []
# log debug calls for some functions in a module:
# module-name:
# - function-name-1
# - function-name-2
musicmuster:
- play_next
jittermonitor: []
handlers:
stderr:
class: colorlog.StreamHandler
level: INFO
formatter: colored
filters: [leveltag]
stream: ext://sys.stderr
syslog:
class: logging.handlers.SysLogHandler
level: DEBUG
formatter: syslog
filters: [leveltag]
address: "/dev/log"
debug_stderr:
class: colorlog.StreamHandler
level: DEBUG
formatter: colored
filters: [leveltag, category_filter]
stream: ext://sys.stderr
loggers:
musicmuster:
level: DEBUG
handlers: [stderr, syslog, debug_stderr]
propagate: false

View File

@ -1,29 +0,0 @@
#!/usr/bin/env python3
from log import log
# Testing
def fa():
log.debug("fa Debug message")
log.info("fa Info message")
log.warning("fa Warning message")
log.error("fa Error message")
log.critical("fa Critical message")
print()
def fb():
log.debug("fb Debug message")
log.info("fb Info message")
log.warning("fb Warning message")
log.error("fb Error message")
log.critical("fb Critical message")
print()
def testing():
fa()
fb()
testing()

View File

@ -1,104 +0,0 @@
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

@ -1,7 +1,5 @@
# Standard library imports # Standard library imports
from __future__ import annotations from typing import List, Optional, Sequence
from typing import Optional, Sequence
import datetime as dt import datetime as dt
import os import os
import re import re
@ -10,27 +8,22 @@ import sys
# PyQt imports # PyQt imports
# Third party imports # Third party imports
from dogpile.cache import make_region
from dogpile.cache.api import NO_VALUE
from sqlalchemy import ( from sqlalchemy import (
bindparam, bindparam,
delete, delete,
func, func,
select, select,
text,
update, update,
) )
from sqlalchemy.exc import IntegrityError, ProgrammingError from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm.exc import NoResultFound from sqlalchemy.orm.exc import NoResultFound
from sqlalchemy.orm import joinedload, selectinload 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 dbmanager import DatabaseManager from dbmanager import DatabaseManager
import dbtables import dbtables
from config import Config
from log import log from log import log
@ -41,28 +34,11 @@ if DATABASE_URL is None:
if "unittest" in sys.modules and "sqlite" not in DATABASE_URL: if "unittest" in sys.modules and "sqlite" not in DATABASE_URL:
raise ValueError("Unit tests running on non-Sqlite database") raise ValueError("Unit tests running on non-Sqlite database")
db = DatabaseManager.get_instance(DATABASE_URL, engine_options=Config.ENGINE_OPTIONS).db db = DatabaseManager.get_instance(DATABASE_URL, engine_options=Config.ENGINE_OPTIONS).db
db.create_all()
# Configure the cache region
cache_region = make_region().configure(
'dogpile.cache.memory', # Use in-memory caching for now (switch to Redis if needed)
expiration_time=600 # Cache expires after 10 minutes
)
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__(
self, self,
session: Session, session: Session,
@ -89,95 +65,62 @@ class NoteColours(dbtables.NoteColoursTable):
Return all records Return all records
""" """
cache_key = "note_colours_all" return session.scalars(select(cls)).all()
cached_result = cache_region.get(cache_key)
if cached_result is not NO_VALUE:
return cached_result
# Query the database
result = session.scalars(
select(cls)
.where(
cls.enabled.is_(True),
)
.order_by(cls.order)
).all()
cache_region.set(cache_key, result)
return result
@staticmethod @staticmethod
def get_colour( def get_colour(session: Session, text: str) -> Optional[str]:
session: Session, text: str, foreground: bool = False
) -> str:
""" """
Parse text and return background (foreground if foreground==True) colour Parse text and return colour string if matched, else empty string
string if matched, else None
""" """
if not text: if not text:
return "" return None
match = False for rec in session.scalars(
for rec in NoteColours.get_all(session): select(NoteColours)
.filter(NoteColours.enabled.is_(True))
.order_by(NoteColours.order)
).all():
if rec.is_regex: if rec.is_regex:
flags = re.UNICODE flags = re.UNICODE
if not rec.is_casesensitive: if not rec.is_casesensitive:
flags |= re.IGNORECASE flags |= re.IGNORECASE
p = re.compile(rec.substring, flags) p = re.compile(rec.substring, flags)
if p.match(text): if p.match(text):
match = True return rec.colour
else: else:
if rec.is_casesensitive: if rec.is_casesensitive:
if rec.substring in text: if rec.substring in text:
match = True return rec.colour
else: else:
if rec.substring.lower() in text.lower(): if rec.substring.lower() in text.lower():
match = True
if match:
if foreground:
return rec.foreground or ""
else:
return rec.colour return rec.colour
return ""
@staticmethod return None
def invalidate_cache() -> None:
"""Invalidate dogpile cache"""
cache_region.delete("note_colours_all")
class Playdates(dbtables.PlaydatesTable): class Playdates(dbtables.PlaydatesTable):
def __init__( def __init__(self, session: Session, track_id: int) -> None:
self, session: Session, track_id: int, when: Optional[dt.datetime] = None
) -> None:
"""Record that track was played""" """Record that track was played"""
if not when:
self.lastplayed = dt.datetime.now() 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()
@staticmethod @staticmethod
def last_playdates( def last_playdates(
session: Session, track_id: int, limit: int = 5 session: Session, track_id: int, limit=5
) -> Sequence["Playdates"]: ) -> Sequence["Playdates"]:
""" """
Return a list of the last limit playdates for this track, sorted Return a list of the last limit playdates for this track, sorted
latest to earliest. earliest to latest.
""" """
return session.scalars( return session.scalars(
Playdates.select() Playdates.select()
.where(Playdates.track_id == track_id) .where(Playdates.track_id == track_id)
.order_by(Playdates.lastplayed.desc()) .order_by(Playdates.lastplayed.asc())
.limit(limit) .limit(limit)
).all() ).all()
@ -200,7 +143,7 @@ class Playdates(dbtables.PlaydatesTable):
return Config.EPOCH # pragma: no cover return Config.EPOCH # pragma: no cover
@staticmethod @staticmethod
def last_played_tracks(session: Session, limit: int = 5) -> Sequence["Playdates"]: def last_played_tracks(session: Session, limit=5) -> Sequence["Playdates"]:
""" """
Return a list of the last limit tracks played, sorted Return a list of the last limit tracks played, sorted
earliest to latest. earliest to latest.
@ -222,20 +165,13 @@ class Playdates(dbtables.PlaydatesTable):
class Playlists(dbtables.PlaylistsTable): class Playlists(dbtables.PlaylistsTable):
def __init__(self, session: Session, name: str, template_id: int) -> None: def __init__(self, session: Session, name: str):
"""Create playlist with passed name"""
self.name = name self.name = name
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:
""" """
Make all tab records NULL Make all tab records NULL
""" """
@ -250,6 +186,30 @@ 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"""
playlist = cls(session, playlist_name)
# Sanity / mypy checks
if not playlist or not playlist.id or not template.id:
return None
PlaylistRows.copy_playlist(session, template.id, playlist.id)
return playlist
def delete(self, session: Session) -> None:
"""
Mark as deleted
"""
self.deleted = True
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"""
@ -265,17 +225,7 @@ class Playlists(dbtables.PlaylistsTable):
"""Returns a list of all templates ordered by name""" """Returns a list of all templates ordered by name"""
return session.scalars( return session.scalars(
select(cls).where(cls.is_template.is_(True)).order_by(cls.name) select(cls).filter(cls.is_template.is_(True)).order_by(cls.name)
).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() ).all()
@classmethod @classmethod
@ -287,6 +237,7 @@ class Playlists(dbtables.PlaylistsTable):
.filter( .filter(
cls.open.is_(False), cls.open.is_(False),
cls.is_template.is_(False), cls.is_template.is_(False),
cls.deleted.is_(False),
) )
.order_by(cls.last_used.desc()) .order_by(cls.last_used.desc())
).all() ).all()
@ -332,7 +283,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_id=0) template = Playlists(session, template_name)
if not template or not template.id: if not template or not template.id:
return return
@ -355,7 +306,7 @@ class PlaylistRows(dbtables.PlaylistRowsTable):
self.playlist_id = playlist_id self.playlist_id = playlist_id
self.track_id = track_id self.track_id = track_id
self.row_number = row_number self.plr_rownum = row_number
self.note = note self.note = note
session.add(self) session.add(self)
session.commit() session.commit()
@ -381,7 +332,7 @@ class PlaylistRows(dbtables.PlaylistRowsTable):
PlaylistRows( PlaylistRows(
session=session, session=session,
playlist_id=dst_id, playlist_id=dst_id,
row_number=plr.row_number, row_number=plr.plr_rownum,
note=plr.note, note=plr.note,
track_id=plr.track_id, track_id=plr.track_id,
) )
@ -400,13 +351,30 @@ class PlaylistRows(dbtables.PlaylistRowsTable):
.options(joinedload(cls.track)) .options(joinedload(cls.track))
.where( .where(
PlaylistRows.playlist_id == playlist_id, PlaylistRows.playlist_id == playlist_id,
PlaylistRows.row_number == row_number, PlaylistRows.plr_rownum == row_number,
) )
# .options(joinedload(Tracks.playdates)) # .options(joinedload(Tracks.playdates))
) )
return session.execute(stmt).unique().scalar_one() return session.execute(stmt).unique().scalar_one()
@classmethod
def deep_rows(cls, session: Session, playlist_id: int) -> Sequence["PlaylistRows"]:
"""
Return a list of playlist rows that include full track and lastplayed data for
given playlist_id., Sequence
"""
stmt = (
select(PlaylistRows)
.options(joinedload(cls.track))
.where(PlaylistRows.playlist_id == playlist_id)
.order_by(PlaylistRows.plr_rownum)
# .options(joinedload(Tracks.playdates))
)
return session.scalars(stmt).unique().all()
@staticmethod @staticmethod
def delete_higher_rows(session: Session, playlist_id: int, maxrow: int) -> None: def delete_higher_rows(session: Session, playlist_id: int, maxrow: int) -> None:
""" """
@ -417,7 +385,7 @@ class PlaylistRows(dbtables.PlaylistRowsTable):
session.execute( session.execute(
delete(PlaylistRows).where( delete(PlaylistRows).where(
PlaylistRows.playlist_id == playlist_id, PlaylistRows.playlist_id == playlist_id,
PlaylistRows.row_number > maxrow, PlaylistRows.plr_rownum > maxrow,
) )
) )
session.commit() session.commit()
@ -431,7 +399,7 @@ class PlaylistRows(dbtables.PlaylistRowsTable):
session.execute( session.execute(
delete(PlaylistRows).where( delete(PlaylistRows).where(
PlaylistRows.playlist_id == playlist_id, PlaylistRows.playlist_id == playlist_id,
PlaylistRows.row_number == row_number, PlaylistRows.plr_rownum == row_number,
) )
) )
@ -444,18 +412,18 @@ class PlaylistRows(dbtables.PlaylistRowsTable):
plrs = session.scalars( plrs = session.scalars(
select(PlaylistRows) select(PlaylistRows)
.where(PlaylistRows.playlist_id == playlist_id) .where(PlaylistRows.playlist_id == playlist_id)
.order_by(PlaylistRows.row_number) .order_by(PlaylistRows.plr_rownum)
).all() ).all()
for i, plr in enumerate(plrs): for i, plr in enumerate(plrs):
plr.row_number = i plr.plr_rownum = i
# Ensure new row numbers are available to the caller # Ensure new row numbers are available to the caller
session.commit() session.commit()
@classmethod @classmethod
def plrids_to_plrs( def plrids_to_plrs(
cls, session: Session, playlist_id: int, plr_ids: list[int] cls, session: Session, playlist_id: int, plr_ids: List[int]
) -> Sequence["PlaylistRows"]: ) -> Sequence["PlaylistRows"]:
""" """
Take a list of PlaylistRows ids and return a list of corresponding Take a list of PlaylistRows ids and return a list of corresponding
@ -465,7 +433,7 @@ class PlaylistRows(dbtables.PlaylistRowsTable):
plrs = session.scalars( plrs = session.scalars(
select(cls) select(cls)
.where(cls.playlist_id == playlist_id, cls.id.in_(plr_ids)) .where(cls.playlist_id == playlist_id, cls.id.in_(plr_ids))
.order_by(cls.row_number) .order_by(cls.plr_rownum)
).all() ).all()
return plrs return plrs
@ -475,7 +443,7 @@ class PlaylistRows(dbtables.PlaylistRowsTable):
"""Return the last used row for playlist, or None if no rows""" """Return the last used row for playlist, or None if no rows"""
return session.execute( return session.execute(
select(func.max(PlaylistRows.row_number)).where( select(func.max(PlaylistRows.plr_rownum)).where(
PlaylistRows.playlist_id == playlist_id PlaylistRows.playlist_id == playlist_id
) )
).scalar_one() ).scalar_one()
@ -507,29 +475,11 @@ class PlaylistRows(dbtables.PlaylistRowsTable):
plrs = session.scalars( plrs = session.scalars(
select(cls) select(cls)
.where(cls.playlist_id == playlist_id, cls.played.is_(True)) .where(cls.playlist_id == playlist_id, cls.played.is_(True))
.order_by(cls.row_number) .order_by(cls.plr_rownum)
).all() ).all()
return plrs return plrs
@classmethod
def get_playlist_rows(
cls, session: Session, playlist_id: int
) -> Sequence["PlaylistRows"]:
"""
For passed playlist, return a list of rows.
"""
stmt = (
select(cls)
.where(cls.playlist_id == playlist_id)
.options(selectinload(cls.track))
.order_by(cls.row_number)
)
plrs = session.execute(stmt).scalars().all()
return plrs
@classmethod @classmethod
def get_rows_with_tracks( def get_rows_with_tracks(
cls, cls,
@ -544,7 +494,7 @@ class PlaylistRows(dbtables.PlaylistRowsTable):
query = select(cls).where( query = select(cls).where(
cls.playlist_id == playlist_id, cls.track_id.is_not(None) cls.playlist_id == playlist_id, cls.track_id.is_not(None)
) )
plrs = session.scalars((query).order_by(cls.row_number)).all() plrs = session.scalars((query).order_by(cls.plr_rownum)).all()
return plrs return plrs
@ -564,7 +514,7 @@ class PlaylistRows(dbtables.PlaylistRowsTable):
cls.track_id.is_not(None), cls.track_id.is_not(None),
cls.played.is_(False), cls.played.is_(False),
) )
.order_by(cls.row_number) .order_by(cls.plr_rownum)
).all() ).all()
return plrs return plrs
@ -602,19 +552,17 @@ class PlaylistRows(dbtables.PlaylistRowsTable):
update(PlaylistRows) update(PlaylistRows)
.where( .where(
(PlaylistRows.playlist_id == playlist_id), (PlaylistRows.playlist_id == playlist_id),
(PlaylistRows.row_number >= starting_row), (PlaylistRows.plr_rownum >= starting_row),
) )
.values(row_number=PlaylistRows.row_number + move_by) .values(plr_rownum=PlaylistRows.plr_rownum + move_by)
) )
@staticmethod @staticmethod
def update_plr_row_numbers( def update_plr_rownumbers(
session: Session, session: Session, playlist_id: int, sqla_map: List[dict[str, int]]
playlist_id: int,
sqla_map: list[dict[str, int]],
) -> None: ) -> None:
""" """
Take a {plrid: row_number} dictionary and update the row numbers accordingly Take a {plrid: plr_rownum} dictionary and update the row numbers accordingly
""" """
# Update database. Ref: # Update database. Ref:
@ -623,46 +571,15 @@ class PlaylistRows(dbtables.PlaylistRowsTable):
update(PlaylistRows) update(PlaylistRows)
.where( .where(
PlaylistRows.playlist_id == playlist_id, PlaylistRows.playlist_id == playlist_id,
PlaylistRows.id == bindparam("playlistrow_id"), PlaylistRows.id == bindparam("plrid"),
) )
.values(row_number=bindparam("row_number")) .values(plr_rownum=bindparam("plr_rownum"))
) )
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):
self.name = name self.name = name
session.add(self) session.add(self)
session.commit() session.commit()
@ -689,8 +606,9 @@ class Tracks(dbtables.TracksTable):
start_gap: int, start_gap: int,
fade_at: int, fade_at: int,
silence_at: int, silence_at: int,
mtime: int,
bitrate: int, bitrate: int,
) -> None: ):
self.path = path self.path = path
self.title = title self.title = title
self.artist = artist self.artist = artist
@ -699,6 +617,7 @@ class Tracks(dbtables.TracksTable):
self.start_gap = start_gap self.start_gap = start_gap
self.fade_at = fade_at self.fade_at = fade_at
self.silence_at = silence_at self.silence_at = silence_at
self.mtime = mtime
try: try:
session.add(self) session.add(self)
@ -709,112 +628,25 @@ class Tracks(dbtables.TracksTable):
raise ValueError(error) raise ValueError(error)
@classmethod @classmethod
def get_all(cls, session: Session) -> Sequence["Tracks"]: def get_all(cls, session) -> List["Tracks"]:
"""Return a list of all tracks""" """Return a list of all tracks"""
return session.scalars(select(cls)).unique().all() return session.scalars(select(cls)).unique().all()
@classmethod @classmethod
def all_tracks_indexed_by_id(cls, session: Session) -> dict[int, Tracks]: def get_by_basename(
cls, session: Session, basename: str
) -> Optional[Sequence["Tracks"]]:
""" """
Return a dictionary of all tracks, keyed by title Return track(s) with passed basename, or None.
""" """
result: dict[int, Tracks] = {} try:
return session.scalars(
for track in cls.get_all(session): Tracks.select().where(Tracks.path.like("%/" + basename))
result[track.id] = track ).all()
except NoResultFound:
return result return None
@classmethod
def exact_title_and_artist(
cls, session: Session, title: str, artist: str
) -> Sequence["Tracks"]:
"""
Search for exact but case-insensitive match of title and artist
"""
return (
session.scalars(
select(cls)
.where(cls.title.ilike(title), cls.artist.ilike(artist))
.order_by(cls.title)
)
.unique()
.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

305
app/pipeclient.py Executable file
View File

@ -0,0 +1,305 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Automate Audacity via mod-script-pipe.
Pipe Client may be used as a command-line script to send commands to
Audacity via the mod-script-pipe interface, or loaded as a module.
Requires Python 3.
(Python 2.7 is now obsolete, so no longer supported)
======================
Command Line Interface
======================
usage: pipeclient.py [-h] [-t] [-s ] [-d]
Arguments
---------
-h,--help: optional
show short help and exit
-t, --timeout: float, optional
timeout for reply in seconds (default: 10)
-s, --show-time: bool, optional
show command execution time (default: True)
-d, --docs: optional
show this documentation and exit
Example
-------
$ python3 pipeclient.py -t 20 -s False
Launches command line interface with 20 second time-out for
returned message, and don't show the execution time.
When prompted, enter the command to send (not quoted), or 'Q' to quit.
$ Enter command or 'Q' to quit: GetInfo: Type=Tracks Format=LISP
============
Module Usage
============
Note that on a critical error (such as broken pipe), the module just exits.
If a more graceful shutdown is required, replace the sys.exit()'s with
exceptions.
Example
-------
# Import the module:
>>> import pipeclient
# Create a client instance:
>>> client = pipeclient.PipeClient()
# Send a command:
>>> client.write("Command", timer=True)
# Read the last reply:
>>> print(client.read())
See Also
--------
PipeClient.write : Write a command to _write_pipe.
PipeClient.read : Read Audacity's reply from pipe.
Copyright Steve Daulton 2018
Released under terms of the GNU General Public License version 2:
<http://www.gnu.org/licenses/old-licenses/gpl-2.0.html />
"""
import os
import sys
import threading
import time
import errno
import argparse
if sys.version_info[0] < 3:
raise RuntimeError("PipeClient Error: Python 3.x required")
# Platform specific constants
if sys.platform == "win32":
WRITE_NAME: str = "\\\\.\\pipe\\ToSrvPipe"
READ_NAME: str = "\\\\.\\pipe\\FromSrvPipe"
EOL: str = "\r\n\0"
else:
# Linux or Mac
PIPE_BASE: str = "/tmp/audacity_script_pipe."
WRITE_NAME: str = PIPE_BASE + "to." + str(os.getuid())
READ_NAME: str = PIPE_BASE + "from." + str(os.getuid())
EOL: str = "\n"
class PipeClient:
"""Write / read client access to Audacity via named pipes.
Normally there should be just one instance of this class. If
more instances are created, they all share the same state.
__init__ calls _write_thread_start() and _read_thread_start() on
first instantiation.
Parameters
----------
None
Attributes
----------
reader_pipe_broken : event object
Set if pipe reader fails. Audacity may have crashed
reply_ready : event object
flag cleared when command sent and set when response received
timer : bool
When true, time the command execution (default False)
reply : string
message received when Audacity completes the command
See Also
--------
write : Write a command to _write_pipe.
read : Read Audacity's reply from pipe.
"""
reader_pipe_broken = threading.Event()
reply_ready = threading.Event()
_shared_state: dict = {}
def __new__(cls, *p, **k):
self = object.__new__(cls, *p, **k)
self.__dict__ = cls._shared_state
return self
def __init__(self):
self.timer: bool = False # type: ignore
self._start_time: float = 0 # type: ignore
self._write_pipe = None
self.reply: str = "" # type: ignore
if not self._write_pipe:
self._write_thread_start()
self._read_thread_start()
def _write_thread_start(self) -> None:
"""Start _write_pipe thread"""
# Pipe is opened in a new thread so that we don't
# freeze if Audacity is not running.
write_thread = threading.Thread(target=self._write_pipe_open)
write_thread.daemon = True
write_thread.start()
# Allow a little time for connection to be made.
time.sleep(0.1)
if not self._write_pipe:
raise RuntimeError("PipeClientError: Write pipe cannot be opened.")
def _write_pipe_open(self) -> None:
"""Open _write_pipe."""
self._write_pipe = open(WRITE_NAME, "w")
def _read_thread_start(self) -> None:
"""Start read_pipe thread."""
read_thread = threading.Thread(target=self._reader)
read_thread.daemon = True
read_thread.start()
def write(self, command, timer=False) -> None:
"""Write a command to _write_pipe.
Parameters
----------
command : string
The command to send to Audacity
timer : bool, optional
If true, time the execution of the command
Example
-------
write("GetInfo: Type=Labels", timer=True):
"""
self.timer = timer
self._write_pipe.write(command + EOL)
# Check that read pipe is alive
if PipeClient.reader_pipe_broken.is_set():
raise RuntimeError("PipeClient: Read-pipe error.")
try:
self._write_pipe.flush()
if self.timer:
self._start_time = time.time()
self.reply = ""
PipeClient.reply_ready.clear()
except IOError as err:
if err.errno == errno.EPIPE:
raise RuntimeError("PipeClient: Write-pipe error.")
else:
raise
def _reader(self) -> None:
"""Read FIFO in worker thread."""
# Thread will wait at this read until it connects.
# Connection should occur as soon as _write_pipe has connected.
with open(READ_NAME, "r") as read_pipe:
message = ""
pipe_ok = True
while pipe_ok:
line = read_pipe.readline()
# Stop timer as soon as we get first line of response.
stop_time = time.time()
while pipe_ok and line != "\n":
message += line
line = read_pipe.readline()
if line == "":
# No data in read_pipe indicates that the pipe
# is broken (Audacity may have crashed).
PipeClient.reader_pipe_broken.set()
pipe_ok = False
if self.timer:
xtime = (stop_time - self._start_time) * 1000
message += f"Execution time: {xtime:.2f}ms"
self.reply = message
PipeClient.reply_ready.set()
message = ""
def read(self) -> str:
"""Read Audacity's reply from pipe.
Returns
-------
string
The reply from the last command sent to Audacity, or null string
if reply not received. Null string usually indicates that Audacity
is still processing the last command.
"""
if not PipeClient.reply_ready.is_set():
return ""
return self.reply
def bool_from_string(strval) -> bool:
"""Return boolean value from string"""
if strval.lower() in ("true", "t", "1", "yes", "y"):
return True
if strval.lower() in ("false", "f", "0", "no", "n"):
return False
raise argparse.ArgumentTypeError("Boolean value expected.")
def main() -> None:
"""Interactive command-line for PipeClient"""
parser = argparse.ArgumentParser()
parser.add_argument(
"-t",
"--timeout",
type=float,
metavar="",
default=10,
help="timeout for reply in seconds (default: 10",
)
parser.add_argument(
"-s",
"--show-time",
metavar="True/False",
nargs="?",
type=bool_from_string,
const="t",
default="t",
dest="show",
help="show command execution time (default: True)",
)
parser.add_argument(
"-d", "--docs", action="store_true", help="show documentation and exit"
)
args = parser.parse_args()
if args.docs:
print(__doc__)
sys.exit(0)
client: PipeClient = PipeClient()
while True:
reply: str = ""
message: str = input("\nEnter command or 'Q' to quit: ")
start = time.time()
if message.upper() == "Q":
sys.exit(0)
elif message == "":
pass
else:
client.write(message, timer=args.show)
while reply == "":
time.sleep(0.1) # allow time for reply
if time.time() - start > args.timeout:
reply = "PipeClient: Reply timed-out."
else:
reply = client.read()
print(reply)
if __name__ == "__main__":
main()

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,290 +0,0 @@
# 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 (
QBrush,
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) -> QBrush:
"""Return background setting"""
# Unreadable track file
if file_is_unreadable(qrow.path):
return QBrush(QColor(Config.COLOUR_UNREADABLE))
# Selected row
if row in self._selected_rows:
return QBrush(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 QBrush(QColor(Config.COLOUR_BITRATE_LOW))
elif qrow.bitrate < Config.BITRATE_OK_THRESHOLD:
return QBrush(QColor(Config.COLOUR_BITRATE_MEDIUM))
else:
return QBrush(QColor(Config.COLOUR_BITRATE_OK))
return QBrush()
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))
or role
in [
Qt.ItemDataRole.CheckStateRole,
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.WhatsThisRole,
]
):
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))
# Fall through to no-op
return QVariant()
def _display_role(self, row: int, column: int, qrow: QueryRow) -> str:
"""
Return text for display
"""
dispatch_table = {
QueryCol.ARTIST.value: qrow.artist,
QueryCol.BITRATE.value: str(qrow.bitrate),
QueryCol.DURATION.value: ms_to_mmss(qrow.duration),
QueryCol.LAST_PLAYED.value: get_relative_date(qrow.lastplayed),
QueryCol.TITLE.value: qrow.title,
}
if column in dispatch_table:
return dispatch_table[column]
return ""
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) -> str | 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 (
"<br>".join(
[
a.lastplayed.strftime(Config.LAST_PLAYED_TOOLTIP_DATE_FORMAT)
for a in reversed(playdates)
]
)
)

View File

@ -2,66 +2,38 @@
from __future__ import annotations from __future__ import annotations
import datetime as dt import datetime as dt
import threading
from time import sleep from time import sleep
from typing import Optional from typing import Optional
# Third party imports # Third party imports
# import line_profiler
import numpy as np import numpy as np
import pyqtgraph as pg # type: ignore import pyqtgraph as pg # type: ignore
from sqlalchemy.orm.session import Session
import vlc # type: ignore import vlc # type: ignore
# PyQt imports # PyQt imports
from PyQt6.QtCore import ( from PyQt6.QtCore import (
pyqtSignal, pyqtSignal,
QObject, QObject,
QRunnable,
QThread, QThread,
QThreadPool,
) )
from pyqtgraph import PlotWidget from pyqtgraph import PlotWidget
from pyqtgraph.graphicsItems.PlotDataItem import PlotDataItem # type: ignore from pyqtgraph.graphicsItems.PlotDataItem import PlotDataItem # type: ignore
from pyqtgraph.graphicsItems.LinearRegionItem import LinearRegionItem # type: ignore from pyqtgraph.graphicsItems.LinearRegionItem import LinearRegionItem # type: ignore
# App imports # App imports
from classes import ApplicationError, MusicMusterSignals from classes import MusicMusterSignals
from config import Config from config import Config
import helpers
from log import log from log import log
from models import PlaylistRows from models import db, PlaylistRows, Tracks
from vlcmanager import VLCManager from helpers import (
file_is_unreadable,
get_audio_segment,
)
lock = threading.Lock()
# Define the VLC callback function type
# import ctypes
# import platform
# VLC logging is very noisy so comment out unless needed
# VLC_LOG_CB = ctypes.CFUNCTYPE(
# None,
# ctypes.c_void_p,
# ctypes.c_int,
# ctypes.c_void_p,
# ctypes.c_char_p,
# ctypes.c_void_p,
# )
# # Determine the correct C library for vsnprintf based on the platform
# if platform.system() == "Windows":
# libc = ctypes.CDLL("msvcrt")
# elif platform.system() == "Linux":
# libc = ctypes.CDLL("libc.so.6")
# elif platform.system() == "Darwin": # macOS
# libc = ctypes.CDLL("libc.dylib")
# else:
# raise OSError("Unsupported operating system")
# # Define the vsnprintf function
# libc.vsnprintf.argtypes = [
# ctypes.c_char_p,
# ctypes.c_size_t,
# ctypes.c_char_p,
# ctypes.c_void_p,
# ]
# libc.vsnprintf.restype = ctypes.c_int
class _AddFadeCurve(QObject): class _AddFadeCurve(QObject):
@ -74,13 +46,13 @@ class _AddFadeCurve(QObject):
def __init__( def __init__(
self, self,
rat: RowAndTrack, track_manager: _TrackManager,
track_path: str, track_path: str,
track_fade_at: int, track_fade_at: int,
track_silence_at: int, track_silence_at: int,
) -> None: ) -> None:
super().__init__() super().__init__()
self.rat = rat self.track_manager = track_manager
self.track_path = track_path self.track_path = track_path
self.track_fade_at = track_fade_at self.track_fade_at = track_fade_at
self.track_silence_at = track_silence_at self.track_silence_at = track_silence_at
@ -94,7 +66,7 @@ class _AddFadeCurve(QObject):
if not fc: if not fc:
log.error(f"Failed to create FadeCurve for {self.track_path=}") log.error(f"Failed to create FadeCurve for {self.track_path=}")
else: else:
self.rat.fade_graph = fc self.track_manager.fade_graph = fc
self.finished.emit() self.finished.emit()
@ -108,7 +80,7 @@ class _FadeCurve:
Set up fade graph array Set up fade graph array
""" """
audio = helpers.get_audio_segment(track_path) audio = get_audio_segment(track_path)
if not audio: if not audio:
log.error(f"FadeCurve: could not get audio for {track_path=}") log.error(f"FadeCurve: could not get audio for {track_path=}")
return None return None
@ -119,8 +91,8 @@ class _FadeCurve:
0, track_fade_at - Config.FADE_CURVE_MS_BEFORE_FADE - 1 0, track_fade_at - Config.FADE_CURVE_MS_BEFORE_FADE - 1
) )
self.end_ms: int = track_silence_at self.end_ms: int = track_silence_at
audio_segment = audio[self.start_ms : self.end_ms] self.audio_segment = audio[self.start_ms : self.end_ms]
self.graph_array = np.array(audio_segment.get_array_of_samples()) self.graph_array = np.array(self.audio_segment.get_array_of_samples())
# Calculate the factor to map milliseconds of track to array # Calculate the factor to map milliseconds of track to array
self.ms_to_array_factor = len(self.graph_array) / (self.end_ms - self.start_ms) self.ms_to_array_factor = len(self.graph_array) / (self.end_ms - self.start_ms)
@ -139,12 +111,8 @@ class _FadeCurve:
self.curve = self.GraphWidget.plot(self.graph_array) self.curve = self.GraphWidget.plot(self.graph_array)
if self.curve: if self.curve:
self.curve.setPen(Config.FADE_CURVE_FOREGROUND) self.curve.setPen(Config.FADE_CURVE_FOREGROUND)
else:
log.debug("_FadeCurve.plot: no curve")
else:
log.debug("_FadeCurve.plot: no GraphWidget")
def tick(self, play_time: int) -> None: def tick(self, play_time) -> None:
"""Update volume fade curve""" """Update volume fade curve"""
if not self.GraphWidget: if not self.GraphWidget:
@ -156,21 +124,21 @@ class _FadeCurve:
if self.region is None: if self.region is None:
# Create the region now that we're into fade # Create the region now that we're into fade
log.debug("issue223: _FadeCurve: create region")
self.region = pg.LinearRegionItem([0, 0], bounds=[0, len(self.graph_array)]) self.region = pg.LinearRegionItem([0, 0], bounds=[0, len(self.graph_array)])
self.GraphWidget.addItem(self.region) self.GraphWidget.addItem(self.region)
# Update region position # Update region position
if self.region: if self.region:
log.debug("issue223: _FadeCurve: update region")
self.region.setRegion([0, ms_of_graph * self.ms_to_array_factor]) self.region.setRegion([0, ms_of_graph * self.ms_to_array_factor])
class _FadeTrack(QThread): class _FadeTrack(QRunnable):
finished = pyqtSignal() def __init__(self, player: vlc.MediaPlayer, fade_seconds) -> None:
def __init__(self, player: vlc.MediaPlayer, fade_seconds: int) -> None:
super().__init__() super().__init__()
self.player = player self.player: vlc.MediaPlayer = player
self.fade_seconds = fade_seconds self.fade_seconds: int = fade_seconds
def run(self) -> None: def run(self) -> None:
""" """
@ -182,7 +150,6 @@ class _FadeTrack(QThread):
# Reduce volume logarithmically # Reduce volume logarithmically
total_steps = self.fade_seconds * Config.FADEOUT_STEPS_PER_SECOND total_steps = self.fade_seconds * Config.FADEOUT_STEPS_PER_SECOND
if total_steps > 0:
db_reduction_per_step = Config.FADEOUT_DB / total_steps db_reduction_per_step = Config.FADEOUT_DB / total_steps
reduction_factor_per_step = pow(10, (db_reduction_per_step / 20)) reduction_factor_per_step = pow(10, (db_reduction_per_step / 20))
@ -194,11 +161,9 @@ class _FadeTrack(QThread):
) )
sleep(1 / Config.FADEOUT_STEPS_PER_SECOND) sleep(1 / Config.FADEOUT_STEPS_PER_SECOND)
self.finished.emit() self.player.stop()
log.debug(f"Releasing player {self.player=}")
self.player.release()
# TODO can we move this into the _Music class?
vlc_instance = VLCManager().vlc_instance
class _Music: class _Music:
@ -206,39 +171,14 @@ class _Music:
Manage the playing of music tracks Manage the playing of music tracks
""" """
def __init__(self, name: str) -> None: def __init__(self, name) -> None:
vlc_instance.set_user_agent(name, name) self.VLC = vlc.Instance()
self.VLC.set_user_agent(name, name)
self.player: Optional[vlc.MediaPlayer] = None self.player: Optional[vlc.MediaPlayer] = None
self.name = name self.name: str = name
self.max_volume: int = Config.VLC_VOLUME_DEFAULT self.max_volume: int = Config.VLC_VOLUME_DEFAULT
self.start_dt: Optional[dt.datetime] = None self.start_dt: Optional[dt.datetime] = None
# Set up logging
# self._set_vlc_log()
# VLC logging very noisy so comment out unless needed
# @VLC_LOG_CB
# def log_callback(data, level, ctx, fmt, args):
# try:
# # Create a ctypes string buffer to hold the formatted message
# buf = ctypes.create_string_buffer(1024)
# # Use vsnprintf to format the string with the va_list
# libc.vsnprintf(buf, len(buf), fmt, args)
# # Decode the formatted message
# message = buf.value.decode("utf-8", errors="replace")
# log.debug("VLC: " + message)
# except Exception as e:
# log.error(f"Error in VLC log callback: {e}")
# def _set_vlc_log(self):
# try:
# vlc.libvlc_log_set(vlc_instance, self.log_callback, None)
# log.debug("VLC logging set up successfully")
# except Exception as e:
# log.error(f"Failed to set up VLC logging: {e}")
def adjust_by_ms(self, ms: int) -> None: def adjust_by_ms(self, ms: int) -> None:
"""Move player position by ms milliseconds""" """Move player position by ms milliseconds"""
@ -260,6 +200,25 @@ class _Music:
else: else:
self.start_dt = dt.datetime.now() - dt.timedelta(milliseconds=ms) self.start_dt = dt.datetime.now() - dt.timedelta(milliseconds=ms)
def stop(self) -> None:
"""Immediately stop playing"""
log.debug(f"Music[{self.name}].stop()")
self.start_dt = None
if not self.player:
return
p = self.player
self.player = None
self.start_dt = None
with lock:
p.stop()
p.release()
p = None
def fade(self, fade_seconds: int) -> None: def fade(self, fade_seconds: int) -> None:
""" """
Fade the currently playing track. Fade the currently playing track.
@ -274,10 +233,23 @@ class _Music:
if not self.player.get_position() > 0 and self.player.is_playing(): if not self.player.get_position() > 0 and self.player.is_playing():
return return
self.fader_worker = _FadeTrack(self.player, fade_seconds=fade_seconds) if fade_seconds <= 0:
self.fader_worker.finished.connect(self.player.release) self.stop()
self.fader_worker.start() return
# Take a copy of current player to allow another track to be
# started without interfering here
with lock:
p = self.player
self.player = None
pool = QThreadPool.globalInstance()
if pool:
fader = _FadeTrack(p, fade_seconds=fade_seconds)
pool.start(fader)
self.start_dt = None self.start_dt = None
else:
log.error("_Music: failed to allocate QThreadPool")
def get_playtime(self) -> int: def get_playtime(self) -> int:
""" """
@ -335,18 +307,13 @@ class _Music:
log.debug(f"Music[{self.name}].play({path=}, {position=}") log.debug(f"Music[{self.name}].play({path=}, {position=}")
if helpers.file_is_unreadable(path): if file_is_unreadable(path):
log.error(f"play({path}): path not readable") log.error(f"play({path}): path not readable")
return None return None
self.player = vlc.MediaPlayer(vlc_instance, path) media = self.VLC.media_new_path(path)
if self.player is None: self.player = media.player_new_from_media()
log.error(f"_Music:play: failed to create MediaPlayer ({path=})") if self.player:
helpers.show_warning(
None, "Error creating MediaPlayer", f"Cannot play file ({path})"
)
return
_ = self.player.play() _ = self.player.play()
self.set_volume(self.max_volume) self.set_volume(self.max_volume)
@ -354,6 +321,20 @@ class _Music:
self.player.set_position(position) self.player.set_position(position)
self.start_dt = start_time self.start_dt = start_time
# For as-yet unknown reasons. sometimes the volume gets
# reset to zero within 200mS or so of starting play. This
# only happened since moving to Debian 12, which uses
# Pipewire for sound (which may be irrelevant).
# It has been known for the volume to need correcting more
# than once in the first 200mS.
for _ in range(3):
if self.player:
volume = self.player.audio_get_volume()
if volume < Config.VLC_VOLUME_DEFAULT:
self.set_volume(Config.VLC_VOLUME_DEFAULT)
log.error(f"Reset from {volume=}")
sleep(0.1)
def set_position(self, position: float) -> None: def set_position(self, position: float) -> None:
""" """
Set player position Set player position
@ -377,100 +358,85 @@ class _Music:
volume = Config.VLC_VOLUME_DEFAULT volume = Config.VLC_VOLUME_DEFAULT
self.player.audio_set_volume(volume) self.player.audio_set_volume(volume)
# Ensure volume correct
def stop(self) -> None: # For as-yet unknown reasons. sometimes the volume gets
"""Immediately stop playing""" # reset to zero within 200mS or so of starting play. This
# only happened since moving to Debian 12, which uses
log.debug(f"Music[{self.name}].stop()") # Pipewire for sound (which may be irrelevant).
for _ in range(3):
self.start_dt = None current_volume = self.player.audio_get_volume()
if current_volume < volume:
if not self.player: self.player.audio_set_volume(volume)
return log.debug(f"Reset from {volume=}")
sleep(0.1)
if self.player.is_playing():
self.player.stop()
self.player.release()
self.player = None
class RowAndTrack: class _TrackManager:
""" """
Object to manage playlist rows and tracks. Object to manage active playlist tracks,
typically the previous, current and next track.
""" """
def __init__(self, playlist_row: PlaylistRows) -> None: def __init__(
self,
session: db.Session,
player_name: str,
track_id: int,
row_number: int,
) -> None:
""" """
Initialises data structure. Initialises data structure.
Define a player.
The passed PlaylistRows object will include a Tracks object if this Raise ValueError if no track in passed plr.
row has a track.
""" """
# Collect playlistrow data track = session.get(Tracks, track_id)
self.note = playlist_row.note if not track:
self.played = playlist_row.played raise ValueError(f"_TrackPlayer: unable to retreived {track_id=}")
self.playlist_id = playlist_row.playlist_id self.player_name = player_name
self.playlistrow_id = playlist_row.id self.row_number = row_number
self.row_number = playlist_row.row_number
self.track_id = playlist_row.track_id
# Playlist display data # Check file readable
self.row_fg: Optional[str] = None if file_is_unreadable(track.path):
self.row_bg: Optional[str] = None raise ValueError(f"_TrackManager.__init__: {track.path=} unreadable")
self.note_fg: Optional[str] = None
self.note_bg: Optional[str] = None
# Collect track data if there's a track self.artist = track.artist
if playlist_row.track_id: self.bitrate = track.bitrate
self.artist = playlist_row.track.artist self.duration = track.duration
self.bitrate = playlist_row.track.bitrate self.fade_at = track.fade_at
self.duration = playlist_row.track.duration self.intro = track.intro
self.fade_at = playlist_row.track.fade_at self.path = track.path
self.intro = playlist_row.track.intro self.silence_at = track.silence_at
if playlist_row.track.playdates: self.start_gap = track.start_gap
self.lastplayed = max( self.title = track.title
[a.lastplayed for a in playlist_row.track.playdates] self.track_id = track.id
)
else:
self.lastplayed = Config.EPOCH
self.path = playlist_row.track.path
self.silence_at = playlist_row.track.silence_at
self.start_gap = playlist_row.track.start_gap
self.title = playlist_row.track.title
else:
self.artist = ""
self.bitrate = 0
self.duration = 0
self.fade_at = 0
self.intro = None
self.lastplayed = Config.EPOCH
self.path = ""
self.silence_at = 0
self.start_gap = 0
self.title = ""
# Track playing data
self.end_of_track_signalled: bool = False
self.end_time: Optional[dt.datetime] = None self.end_time: Optional[dt.datetime] = None
self.fade_graph: Optional[_FadeCurve] = None self.fade_graph: Optional[_FadeCurve] = None
self.fade_graph_start_updates: Optional[dt.datetime] = None self.fade_graph_start_updates: Optional[dt.datetime] = None
self.resume_marker: Optional[float] = 0.0 self.resume_marker: Optional[float]
self.forecast_end_time: Optional[dt.datetime] = None
self.forecast_start_time: Optional[dt.datetime] = None
self.start_time: Optional[dt.datetime] = None self.start_time: Optional[dt.datetime] = None
self.end_of_track_signalled: bool = False
# Other object initialisation
self.music = _Music(name=Config.VLC_MAIN_PLAYER_NAME)
self.signals = MusicMusterSignals() self.signals = MusicMusterSignals()
def __repr__(self) -> str: # Initialise player
return ( self.player = _Music(name=player_name)
f"<RowAndTrack(playlist_id={self.playlist_id}, "
f"row_number={self.row_number}, " # Initialise and add FadeCurve in a thread as it's slow
f"playlistrow_id={self.playlistrow_id}, " self.fadecurve_thread = QThread()
f"note={self.note}, track_id={self.track_id}>" self.worker = _AddFadeCurve(
self,
track_path=track.path,
track_fade_at=track.fade_at,
track_silence_at=track.silence_at,
) )
self.worker.moveToThread(self.fadecurve_thread)
self.fadecurve_thread.started.connect(self.worker.run)
self.worker.finished.connect(self.fadecurve_thread.quit)
self.worker.finished.connect(self.worker.deleteLater)
self.fadecurve_thread.finished.connect(self.fadecurve_thread.deleteLater)
self.fadecurve_thread.start()
def check_for_end_of_track(self) -> None: def check_for_end_of_track(self) -> None:
""" """
@ -483,52 +449,29 @@ class RowAndTrack:
if self.end_of_track_signalled: if self.end_of_track_signalled:
return return
if self.music.is_playing(): if not self.player.is_playing():
return
self.start_time = None self.start_time = None
if self.fade_graph: if self.fade_graph:
self.fade_graph.clear() self.fade_graph.clear()
# Ensure that player is released self.signal_end_of_track()
self.music.fade(0)
self.signals.track_ended_signal.emit()
self.end_of_track_signalled = True self.end_of_track_signalled = True
def create_fade_graph(self) -> None:
"""
Initialise and add FadeCurve in a thread as it's slow
"""
self.fadecurve_thread = QThread()
self.worker = _AddFadeCurve(
self,
track_path=self.path,
track_fade_at=self.fade_at,
track_silence_at=self.silence_at,
)
self.worker.moveToThread(self.fadecurve_thread)
self.fadecurve_thread.started.connect(self.worker.run)
self.worker.finished.connect(self.fadecurve_thread.quit)
self.worker.finished.connect(self.worker.deleteLater)
self.fadecurve_thread.finished.connect(self.fadecurve_thread.deleteLater)
self.fadecurve_thread.start()
def drop3db(self, enable: bool) -> None: def drop3db(self, enable: bool) -> None:
""" """
If enable is true, drop output by 3db else restore to full volume If enable is true, drop output by 3db else restore to full volume
""" """
if enable: if enable:
self.music.set_volume(volume=Config.VLC_VOLUME_DROP3db, set_default=False) self.player.set_volume(volume=Config.VLC_VOLUME_DROP3db, set_default=False)
else: else:
self.music.set_volume(volume=Config.VLC_VOLUME_DEFAULT, set_default=False) self.player.set_volume(volume=Config.VLC_VOLUME_DEFAULT, set_default=False)
def fade(self, fade_seconds: int = Config.FADEOUT_SECONDS) -> None: def fade(self, fade_seconds: int = Config.FADEOUT_SECONDS) -> None:
"""Fade music""" """Fade music"""
self.resume_marker = self.music.get_position() self.resume_marker = self.player.get_position()
self.music.fade(fade_seconds) self.player.fade(fade_seconds)
self.signals.track_ended_signal.emit() self.signal_end_of_track()
def is_playing(self) -> bool: def is_playing(self) -> bool:
""" """
@ -538,32 +481,45 @@ class RowAndTrack:
if self.start_time is None: if self.start_time is None:
return False return False
return self.music.is_playing() return self.player.is_playing()
def move_back(self, ms: int = Config.PREVIEW_BACK_MS) -> None: def move_back(self, ms: int = Config.PREVIEW_BACK_MS) -> None:
""" """
Rewind player by ms milliseconds Rewind player by ms milliseconds
""" """
self.music.adjust_by_ms(ms * -1) self.player.adjust_by_ms(ms * -1)
def move_forward(self, ms: int = Config.PREVIEW_ADVANCE_MS) -> None: def move_forward(self, ms: int = Config.PREVIEW_ADVANCE_MS) -> None:
""" """
Rewind player by ms milliseconds Rewind player by ms milliseconds
""" """
self.music.adjust_by_ms(ms) self.player.adjust_by_ms(ms)
def move_to_intro_end(self, buffer: int = Config.PREVIEW_END_BUFFER_MS) -> None:
"""
Move play position to 'buffer' milliseconds before end of intro.
If no intro defined, do nothing.
"""
if self.intro is None:
return
new_position = max(0, self.intro - Config.PREVIEW_END_BUFFER_MS)
self.player.adjust_by_ms(new_position - self.time_playing())
def play(self, position: Optional[float] = None) -> None: def play(self, position: Optional[float] = None) -> None:
"""Play track""" """Play track"""
log.debug(f"issue223: _TrackManager: play {self.track_id=}")
now = dt.datetime.now() now = dt.datetime.now()
self.start_time = now self.start_time = now
# Initialise player self.player.play(self.path, start_time=now, position=position)
self.music.play(self.path, start_time=now, position=position)
self.end_time = now + dt.timedelta(milliseconds=self.duration) self.end_time = self.start_time + dt.timedelta(milliseconds=self.duration)
# Calculate time fade_graph should start updating # Calculate time fade_graph should start updating
if self.fade_at: if self.fade_at:
@ -579,47 +535,19 @@ class RowAndTrack:
Restart player Restart player
""" """
self.music.adjust_by_ms(self.time_playing() * -1) self.player.adjust_by_ms(self.time_playing() * -1)
def set_forecast_start_time( def signal_end_of_track(self) -> None:
self, modified_rows: list[int], start: Optional[dt.datetime]
) -> Optional[dt.datetime]:
""" """
Set forecast start time for this row Send end of track signal unless we are a preview player
Update passed modified rows list if we changed the row.
Return new start time
""" """
changed = False
if self.forecast_start_time != start:
self.forecast_start_time = start
changed = True
if start is None:
if self.forecast_end_time is not None:
self.forecast_end_time = None
changed = True
new_start_time = None
else:
end_time = start + dt.timedelta(milliseconds=self.duration)
new_start_time = end_time
if self.forecast_end_time != end_time:
self.forecast_end_time = end_time
changed = True
if changed and self.row_number not in modified_rows:
modified_rows.append(self.row_number)
return new_start_time
def stop(self, fade_seconds: int = 0) -> None: def stop(self, fade_seconds: int = 0) -> None:
""" """
Stop this track playing Stop this track playing
""" """
self.resume_marker = self.music.get_position() self.resume_marker = self.player.get_position()
self.fade(fade_seconds) self.fade(fade_seconds)
# Reset fade graph # Reset fade graph
@ -634,7 +562,7 @@ class RowAndTrack:
if self.start_time is None: if self.start_time is None:
return 0 return 0
return self.music.get_playtime() return self.player.get_playtime()
def time_remaining_intro(self) -> int: def time_remaining_intro(self) -> int:
""" """
@ -686,39 +614,46 @@ class RowAndTrack:
self.fade_graph.tick(self.time_playing()) self.fade_graph.tick(self.time_playing())
def update_playlist_and_row(self, session: Session) -> None:
class MainTrackManager(_TrackManager):
""" """
Update local playlist_id and row_number from playlistrow_id Manage playing tracks from the playlist with associated data
""" """
plr = session.get(PlaylistRows, self.playlistrow_id) def __init__(self, session: db.Session, plr_id: int) -> None:
"""
Set up manager for playlist tracks
"""
# Ensure we have a track
plr = session.get(PlaylistRows, plr_id)
if not plr: if not plr:
raise ApplicationError(f"(Can't retrieve PlaylistRows entry, {self=}") raise ValueError(f"PlaylistTrack: unable to retreive plr {plr_id=}")
self.playlist_id = plr.playlist_id
self.row_number = plr.row_number self.track_id: int = plr.track_id
super().__init__(
session=session,
player_name=Config.VLC_MAIN_PLAYER_NAME,
track_id=self.track_id,
row_number=plr.plr_rownum,
)
# Save non-track plr info
self.plr_id: int = plr.id
self.playlist_id: int = plr.playlist_id
def __repr__(self) -> str:
return (
f"<MainTrackManager(plr_id={self.plr_id}, playlist_id={self.playlist_id}, "
f"row_number={self.row_number}>"
)
class TrackSequence: class TrackSequence:
next: Optional[RowAndTrack] = None next: Optional[MainTrackManager] = None
current: Optional[RowAndTrack] = None current: Optional[MainTrackManager] = None
previous: Optional[RowAndTrack] = None previous: Optional[MainTrackManager] = None
def set_next(self, rat: Optional[RowAndTrack]) -> None:
"""
Set the 'next' track to be passed rat. Clear
any previous next track. If passed rat is None
just clear existing next track.
"""
# Clear any existing fade graph
if self.next and self.next.fade_graph:
self.next.fade_graph.clear()
if rat is None:
self.next = None
else:
self.next = rat
self.next.create_fade_graph()
track_sequence = TrackSequence() track_sequence = TrackSequence()

View File

@ -1,94 +0,0 @@
<?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>

View File

@ -1,45 +0,0 @@
# 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"))

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

View File

@ -1,8 +1,5 @@
<RCC> <RCC>
<qresource prefix="icons"> <qresource prefix="icons">
<file>yellow-circle.png</file>
<file>redstar.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>record-red-button.png</file> <file>record-red-button.png</file>

File diff suppressed because it is too large Load Diff

View File

@ -967,70 +967,68 @@ padding-left: 8px;</string>
</property> </property>
<widget class="QMenu" name="menuFile"> <widget class="QMenu" name="menuFile">
<property name="title"> <property name="title">
<string>&amp;Playlist</string> <string>&amp;Playlists</string>
</property> </property>
<addaction name="separator"/>
<addaction name="actionInsertTrack"/>
<addaction name="actionRemove"/>
<addaction name="actionInsertSectionHeader"/>
<addaction name="separator"/>
<addaction name="actionMark_for_moving"/>
<addaction name="actionPaste"/>
<addaction name="separator"/>
<addaction name="actionExport_playlist"/>
<addaction name="actionDownload_CSV_of_played_tracks"/>
<addaction name="separator"/>
<addaction name="actionSelect_duplicate_rows"/>
<addaction name="actionMoveSelected"/>
<addaction name="actionMoveUnplayed"/>
<addaction name="action_Clear_selection"/>
</widget>
<widget class="QMenu" name="menuPlaylist">
<property name="title">
<string>&amp;File</string>
</property>
<addaction name="separator"/>
<addaction name="separator"/>
<addaction name="actionOpenPlaylist"/>
<addaction name="actionNewPlaylist"/> <addaction name="actionNewPlaylist"/>
<addaction name="actionNew_from_template"/>
<addaction name="actionOpenPlaylist"/>
<addaction name="actionClosePlaylist"/> <addaction name="actionClosePlaylist"/>
<addaction name="actionRenamePlaylist"/> <addaction name="actionRenamePlaylist"/>
<addaction name="actionDeletePlaylist"/> <addaction name="actionDeletePlaylist"/>
<addaction name="actionExport_playlist"/>
<addaction name="separator"/> <addaction name="separator"/>
<addaction name="actionOpenQuerylist"/> <addaction name="actionSelect_duplicate_rows"/>
<addaction name="actionManage_querylists"/>
<addaction name="separator"/> <addaction name="separator"/>
<addaction name="actionMoveSelected"/>
<addaction name="actionMoveUnplayed"/>
<addaction name="actionDownload_CSV_of_played_tracks"/>
<addaction name="actionSave_as_template"/> <addaction name="actionSave_as_template"/>
<addaction name="actionManage_templates"/>
<addaction name="separator"/> <addaction name="separator"/>
<addaction name="actionImport_files"/> <addaction name="actionReplace_files"/>
<addaction name="separator"/> <addaction name="separator"/>
<addaction name="actionE_xit"/> <addaction name="actionE_xit"/>
</widget> </widget>
<widget class="QMenu" name="menuSearc_h"> <widget class="QMenu" name="menuPlaylist">
<property name="title"> <property name="title">
<string>&amp;Music</string> <string>Sho&amp;wtime</string>
</property> </property>
<addaction name="actionSetNext"/> <addaction name="separator"/>
<addaction name="actionPlay_next"/> <addaction name="actionPlay_next"/>
<addaction name="actionFade"/> <addaction name="actionFade"/>
<addaction name="actionStop"/> <addaction name="actionStop"/>
<addaction name="actionResume"/> <addaction name="actionResume"/>
<addaction name="separator"/>
<addaction name="actionSkipToNext"/> <addaction name="actionSkipToNext"/>
<addaction name="separator"/> <addaction name="separator"/>
<addaction name="actionInsertSectionHeader"/>
<addaction name="actionInsertTrack"/>
<addaction name="actionRemove"/>
<addaction name="actionImport"/>
<addaction name="separator"/>
<addaction name="actionSetNext"/>
<addaction name="action_Clear_selection"/>
<addaction name="separator"/>
<addaction name="actionMark_for_moving"/>
<addaction name="actionPaste"/>
</widget>
<widget class="QMenu" name="menuSearc_h">
<property name="title">
<string>&amp;Search</string>
</property>
<addaction name="actionSearch"/> <addaction name="actionSearch"/>
<addaction name="separator"/>
<addaction name="actionSearch_title_in_Wikipedia"/> <addaction name="actionSearch_title_in_Wikipedia"/>
<addaction name="actionSearch_title_in_Songfacts"/> <addaction name="actionSearch_title_in_Songfacts"/>
</widget> </widget>
<widget class="QMenu" name="menuHelp"> <widget class="QMenu" name="menuHelp">
<property name="title"> <property name="title">
<string>Help</string> <string>&amp;Help</string>
</property> </property>
<addaction name="action_About"/> <addaction name="action_About"/>
<addaction name="actionDebug"/> <addaction name="actionDebug"/>
</widget> </widget>
<addaction name="menuPlaylist"/>
<addaction name="menuFile"/> <addaction name="menuFile"/>
<addaction name="menuPlaylist"/>
<addaction name="menuSearc_h"/> <addaction name="menuSearc_h"/>
<addaction name="menuHelp"/> <addaction name="menuHelp"/>
</widget> </widget>
@ -1307,9 +1305,9 @@ padding-left: 8px;</string>
<string>Save as template...</string> <string>Save as template...</string>
</property> </property>
</action> </action>
<action name="actionManage_templates"> <action name="actionNew_from_template">
<property name="text"> <property name="text">
<string>Manage templates...</string> <string>New from template...</string>
</property> </property>
</action> </action>
<action name="actionDebug"> <action name="actionDebug">
@ -1367,19 +1365,9 @@ padding-left: 8px;</string>
<string>Select duplicate rows...</string> <string>Select duplicate rows...</string>
</property> </property>
</action> </action>
<action name="actionImport_files"> <action name="actionReplace_files">
<property name="text"> <property name="text">
<string>Import files...</string> <string>Replace files...</string>
</property>
</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> </property>
</action> </action>
</widget> </widget>

View File

@ -1,589 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>FooterSection</class>
<widget class="QWidget" name="FooterSection">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>1237</width>
<height>154</height>
</rect>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
<layout class="QHBoxLayout" name="horizontalLayout_2">
<item>
<widget class="QFrame" name="InfoFooterFrame">
<property name="maximumSize">
<size>
<width>16777215</width>
<height>16777215</height>
</size>
</property>
<property name="styleSheet">
<string notr="true">background-color: rgb(192, 191, 188)</string>
</property>
<property name="frameShape">
<enum>QFrame::StyledPanel</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Raised</enum>
</property>
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<widget class="QFrame" name="FadeStopInfoFrame">
<property name="minimumSize">
<size>
<width>152</width>
<height>112</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>184</width>
<height>16777215</height>
</size>
</property>
<property name="frameShape">
<enum>QFrame::StyledPanel</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Raised</enum>
</property>
<layout class="QVBoxLayout" name="verticalLayout_4">
<item>
<widget class="QPushButton" name="btnPreview">
<property name="minimumSize">
<size>
<width>132</width>
<height>41</height>
</size>
</property>
<property name="text">
<string> Preview</string>
</property>
<property name="icon">
<iconset resource="icons.qrc">
<normaloff>:/icons/headphones</normaloff>:/icons/headphones</iconset>
</property>
<property name="iconSize">
<size>
<width>30</width>
<height>30</height>
</size>
</property>
<property name="checkable">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QGroupBox" name="groupBoxIntroControls">
<property name="minimumSize">
<size>
<width>132</width>
<height>46</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>132</width>
<height>46</height>
</size>
</property>
<property name="title">
<string/>
</property>
<widget class="QPushButton" name="btnPreviewStart">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>44</width>
<height>23</height>
</rect>
</property>
<property name="minimumSize">
<size>
<width>44</width>
<height>23</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>44</width>
<height>23</height>
</size>
</property>
<property name="text">
<string>&lt;&lt;</string>
</property>
</widget>
<widget class="QPushButton" name="btnPreviewArm">
<property name="geometry">
<rect>
<x>44</x>
<y>0</y>
<width>44</width>
<height>23</height>
</rect>
</property>
<property name="minimumSize">
<size>
<width>44</width>
<height>23</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>44</width>
<height>23</height>
</size>
</property>
<property name="text">
<string/>
</property>
<property name="icon">
<iconset resource="icons.qrc">
<normaloff>:/icons/record-button.png</normaloff>
<normalon>:/icons/record-red-button.png</normalon>:/icons/record-button.png</iconset>
</property>
<property name="checkable">
<bool>true</bool>
</property>
</widget>
<widget class="QPushButton" name="btnPreviewEnd">
<property name="geometry">
<rect>
<x>88</x>
<y>0</y>
<width>44</width>
<height>23</height>
</rect>
</property>
<property name="minimumSize">
<size>
<width>44</width>
<height>23</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>44</width>
<height>23</height>
</size>
</property>
<property name="text">
<string>&gt;&gt;</string>
</property>
</widget>
<widget class="QPushButton" name="btnPreviewBack">
<property name="geometry">
<rect>
<x>0</x>
<y>23</y>
<width>44</width>
<height>23</height>
</rect>
</property>
<property name="minimumSize">
<size>
<width>44</width>
<height>23</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>44</width>
<height>23</height>
</size>
</property>
<property name="text">
<string>&lt;</string>
</property>
</widget>
<widget class="QPushButton" name="btnPreviewMark">
<property name="enabled">
<bool>false</bool>
</property>
<property name="geometry">
<rect>
<x>44</x>
<y>23</y>
<width>44</width>
<height>23</height>
</rect>
</property>
<property name="minimumSize">
<size>
<width>44</width>
<height>23</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>44</width>
<height>23</height>
</size>
</property>
<property name="text">
<string/>
</property>
<property name="icon">
<iconset>
<normalon>:/icons/star.png</normalon>
<disabledoff>:/icons/star_empty.png</disabledoff>
</iconset>
</property>
</widget>
<widget class="QPushButton" name="btnPreviewFwd">
<property name="geometry">
<rect>
<x>88</x>
<y>23</y>
<width>44</width>
<height>23</height>
</rect>
</property>
<property name="minimumSize">
<size>
<width>44</width>
<height>23</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>44</width>
<height>23</height>
</size>
</property>
<property name="text">
<string>&gt;</string>
</property>
</widget>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QFrame" name="frame_intro">
<property name="minimumSize">
<size>
<width>152</width>
<height>112</height>
</size>
</property>
<property name="styleSheet">
<string notr="true"/>
</property>
<property name="frameShape">
<enum>QFrame::StyledPanel</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Raised</enum>
</property>
<layout class="QVBoxLayout" name="verticalLayout_9">
<item>
<widget class="QLabel" name="label_7">
<property name="text">
<string>Intro</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="label_intro_timer">
<property name="font">
<font>
<family>FreeSans</family>
<pointsize>40</pointsize>
<weight>50</weight>
<bold>false</bold>
</font>
</property>
<property name="text">
<string>0:0</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QFrame" name="frame_toggleplayed_3db">
<property name="minimumSize">
<size>
<width>152</width>
<height>112</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>184</width>
<height>16777215</height>
</size>
</property>
<property name="frameShape">
<enum>QFrame::StyledPanel</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Raised</enum>
</property>
<layout class="QVBoxLayout" name="verticalLayout_6">
<item>
<widget class="QPushButton" name="btnDrop3db">
<property name="minimumSize">
<size>
<width>132</width>
<height>41</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>164</width>
<height>16777215</height>
</size>
</property>
<property name="text">
<string>-3dB to talk</string>
</property>
<property name="checkable">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="btnHidePlayed">
<property name="minimumSize">
<size>
<width>132</width>
<height>41</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>164</width>
<height>16777215</height>
</size>
</property>
<property name="text">
<string>Hide played</string>
</property>
<property name="checkable">
<bool>true</bool>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QFrame" name="frame_fade">
<property name="minimumSize">
<size>
<width>152</width>
<height>112</height>
</size>
</property>
<property name="styleSheet">
<string notr="true"/>
</property>
<property name="frameShape">
<enum>QFrame::StyledPanel</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Raised</enum>
</property>
<layout class="QVBoxLayout" name="verticalLayout_2">
<item>
<widget class="QLabel" name="label_4">
<property name="text">
<string>Fade</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="label_fade_timer">
<property name="font">
<font>
<family>FreeSans</family>
<pointsize>40</pointsize>
<weight>50</weight>
<bold>false</bold>
</font>
</property>
<property name="text">
<string>00:00</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QFrame" name="frame_silent">
<property name="minimumSize">
<size>
<width>152</width>
<height>112</height>
</size>
</property>
<property name="styleSheet">
<string notr="true"/>
</property>
<property name="frameShape">
<enum>QFrame::StyledPanel</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Raised</enum>
</property>
<layout class="QVBoxLayout" name="verticalLayout_7">
<item>
<widget class="QLabel" name="label_5">
<property name="text">
<string>Silent</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="label_silent_timer">
<property name="font">
<font>
<family>FreeSans</family>
<pointsize>40</pointsize>
<weight>50</weight>
<bold>false</bold>
</font>
</property>
<property name="text">
<string>00:00</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="PlotWidget" name="widgetFadeVolume" native="true">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
<horstretch>1</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>0</width>
<height>0</height>
</size>
</property>
</widget>
</item>
<item>
<widget class="QFrame" name="frame">
<property name="minimumSize">
<size>
<width>151</width>
<height>0</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>151</width>
<height>112</height>
</size>
</property>
<property name="frameShape">
<enum>QFrame::StyledPanel</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Raised</enum>
</property>
<layout class="QVBoxLayout" name="verticalLayout_5">
<item>
<widget class="QPushButton" name="btnFade">
<property name="minimumSize">
<size>
<width>132</width>
<height>32</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>164</width>
<height>16777215</height>
</size>
</property>
<property name="text">
<string> Fade</string>
</property>
<property name="icon">
<iconset resource="icons.qrc">
<normaloff>:/icons/fade</normaloff>:/icons/fade</iconset>
</property>
<property name="iconSize">
<size>
<width>30</width>
<height>30</height>
</size>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="btnStop">
<property name="minimumSize">
<size>
<width>0</width>
<height>36</height>
</size>
</property>
<property name="text">
<string> Stop</string>
</property>
<property name="icon">
<iconset resource="icons.qrc">
<normaloff>:/icons/stopsign</normaloff>:/icons/stopsign</iconset>
</property>
</widget>
</item>
</layout>
</widget>
</item>
</layout>
</widget>
</item>
</layout>
</widget>
<customwidgets>
<customwidget>
<class>PlotWidget</class>
<extends>QWidget</extends>
<header>pyqtgraph</header>
<container>1</container>
</customwidget>
</customwidgets>
<resources>
<include location="icons.qrc"/>
</resources>
<connections/>
</ui>

View File

@ -1,274 +0,0 @@
# Form implementation generated from reading ui file 'app/ui/main_window_footer.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_FooterSection(object):
def setupUi(self, FooterSection):
FooterSection.setObjectName("FooterSection")
FooterSection.resize(1237, 154)
self.horizontalLayout_2 = QtWidgets.QHBoxLayout(FooterSection)
self.horizontalLayout_2.setObjectName("horizontalLayout_2")
self.InfoFooterFrame = QtWidgets.QFrame(parent=FooterSection)
self.InfoFooterFrame.setMaximumSize(QtCore.QSize(16777215, 16777215))
self.InfoFooterFrame.setStyleSheet("background-color: rgb(192, 191, 188)")
self.InfoFooterFrame.setFrameShape(QtWidgets.QFrame.Shape.StyledPanel)
self.InfoFooterFrame.setFrameShadow(QtWidgets.QFrame.Shadow.Raised)
self.InfoFooterFrame.setObjectName("InfoFooterFrame")
self.horizontalLayout = QtWidgets.QHBoxLayout(self.InfoFooterFrame)
self.horizontalLayout.setObjectName("horizontalLayout")
self.FadeStopInfoFrame = QtWidgets.QFrame(parent=self.InfoFooterFrame)
self.FadeStopInfoFrame.setMinimumSize(QtCore.QSize(152, 112))
self.FadeStopInfoFrame.setMaximumSize(QtCore.QSize(184, 16777215))
self.FadeStopInfoFrame.setFrameShape(QtWidgets.QFrame.Shape.StyledPanel)
self.FadeStopInfoFrame.setFrameShadow(QtWidgets.QFrame.Shadow.Raised)
self.FadeStopInfoFrame.setObjectName("FadeStopInfoFrame")
self.verticalLayout_4 = QtWidgets.QVBoxLayout(self.FadeStopInfoFrame)
self.verticalLayout_4.setObjectName("verticalLayout_4")
self.btnPreview = QtWidgets.QPushButton(parent=self.FadeStopInfoFrame)
self.btnPreview.setMinimumSize(QtCore.QSize(132, 41))
icon = QtGui.QIcon()
icon.addPixmap(
QtGui.QPixmap(":/icons/headphones"),
QtGui.QIcon.Mode.Normal,
QtGui.QIcon.State.Off,
)
self.btnPreview.setIcon(icon)
self.btnPreview.setIconSize(QtCore.QSize(30, 30))
self.btnPreview.setCheckable(True)
self.btnPreview.setObjectName("btnPreview")
self.verticalLayout_4.addWidget(self.btnPreview)
self.groupBoxIntroControls = QtWidgets.QGroupBox(parent=self.FadeStopInfoFrame)
self.groupBoxIntroControls.setMinimumSize(QtCore.QSize(132, 46))
self.groupBoxIntroControls.setMaximumSize(QtCore.QSize(132, 46))
self.groupBoxIntroControls.setTitle("")
self.groupBoxIntroControls.setObjectName("groupBoxIntroControls")
self.btnPreviewStart = QtWidgets.QPushButton(parent=self.groupBoxIntroControls)
self.btnPreviewStart.setGeometry(QtCore.QRect(0, 0, 44, 23))
self.btnPreviewStart.setMinimumSize(QtCore.QSize(44, 23))
self.btnPreviewStart.setMaximumSize(QtCore.QSize(44, 23))
self.btnPreviewStart.setObjectName("btnPreviewStart")
self.btnPreviewArm = QtWidgets.QPushButton(parent=self.groupBoxIntroControls)
self.btnPreviewArm.setGeometry(QtCore.QRect(44, 0, 44, 23))
self.btnPreviewArm.setMinimumSize(QtCore.QSize(44, 23))
self.btnPreviewArm.setMaximumSize(QtCore.QSize(44, 23))
self.btnPreviewArm.setText("")
icon1 = QtGui.QIcon()
icon1.addPixmap(
QtGui.QPixmap(":/icons/record-button.png"),
QtGui.QIcon.Mode.Normal,
QtGui.QIcon.State.Off,
)
icon1.addPixmap(
QtGui.QPixmap(":/icons/record-red-button.png"),
QtGui.QIcon.Mode.Normal,
QtGui.QIcon.State.On,
)
self.btnPreviewArm.setIcon(icon1)
self.btnPreviewArm.setCheckable(True)
self.btnPreviewArm.setObjectName("btnPreviewArm")
self.btnPreviewEnd = QtWidgets.QPushButton(parent=self.groupBoxIntroControls)
self.btnPreviewEnd.setGeometry(QtCore.QRect(88, 0, 44, 23))
self.btnPreviewEnd.setMinimumSize(QtCore.QSize(44, 23))
self.btnPreviewEnd.setMaximumSize(QtCore.QSize(44, 23))
self.btnPreviewEnd.setObjectName("btnPreviewEnd")
self.btnPreviewBack = QtWidgets.QPushButton(parent=self.groupBoxIntroControls)
self.btnPreviewBack.setGeometry(QtCore.QRect(0, 23, 44, 23))
self.btnPreviewBack.setMinimumSize(QtCore.QSize(44, 23))
self.btnPreviewBack.setMaximumSize(QtCore.QSize(44, 23))
self.btnPreviewBack.setObjectName("btnPreviewBack")
self.btnPreviewMark = QtWidgets.QPushButton(parent=self.groupBoxIntroControls)
self.btnPreviewMark.setEnabled(False)
self.btnPreviewMark.setGeometry(QtCore.QRect(44, 23, 44, 23))
self.btnPreviewMark.setMinimumSize(QtCore.QSize(44, 23))
self.btnPreviewMark.setMaximumSize(QtCore.QSize(44, 23))
self.btnPreviewMark.setText("")
icon2 = QtGui.QIcon()
icon2.addPixmap(
QtGui.QPixmap(":/icons/star.png"),
QtGui.QIcon.Mode.Normal,
QtGui.QIcon.State.On,
)
icon2.addPixmap(
QtGui.QPixmap(":/icons/star_empty.png"),
QtGui.QIcon.Mode.Disabled,
QtGui.QIcon.State.Off,
)
self.btnPreviewMark.setIcon(icon2)
self.btnPreviewMark.setObjectName("btnPreviewMark")
self.btnPreviewFwd = QtWidgets.QPushButton(parent=self.groupBoxIntroControls)
self.btnPreviewFwd.setGeometry(QtCore.QRect(88, 23, 44, 23))
self.btnPreviewFwd.setMinimumSize(QtCore.QSize(44, 23))
self.btnPreviewFwd.setMaximumSize(QtCore.QSize(44, 23))
self.btnPreviewFwd.setObjectName("btnPreviewFwd")
self.verticalLayout_4.addWidget(self.groupBoxIntroControls)
self.horizontalLayout.addWidget(self.FadeStopInfoFrame)
self.frame_intro = QtWidgets.QFrame(parent=self.InfoFooterFrame)
self.frame_intro.setMinimumSize(QtCore.QSize(152, 112))
self.frame_intro.setStyleSheet("")
self.frame_intro.setFrameShape(QtWidgets.QFrame.Shape.StyledPanel)
self.frame_intro.setFrameShadow(QtWidgets.QFrame.Shadow.Raised)
self.frame_intro.setObjectName("frame_intro")
self.verticalLayout_9 = QtWidgets.QVBoxLayout(self.frame_intro)
self.verticalLayout_9.setObjectName("verticalLayout_9")
self.label_7 = QtWidgets.QLabel(parent=self.frame_intro)
self.label_7.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
self.label_7.setObjectName("label_7")
self.verticalLayout_9.addWidget(self.label_7)
self.label_intro_timer = QtWidgets.QLabel(parent=self.frame_intro)
font = QtGui.QFont()
font.setFamily("FreeSans")
font.setPointSize(40)
font.setBold(False)
font.setWeight(50)
self.label_intro_timer.setFont(font)
self.label_intro_timer.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
self.label_intro_timer.setObjectName("label_intro_timer")
self.verticalLayout_9.addWidget(self.label_intro_timer)
self.horizontalLayout.addWidget(self.frame_intro)
self.frame_toggleplayed_3db = QtWidgets.QFrame(parent=self.InfoFooterFrame)
self.frame_toggleplayed_3db.setMinimumSize(QtCore.QSize(152, 112))
self.frame_toggleplayed_3db.setMaximumSize(QtCore.QSize(184, 16777215))
self.frame_toggleplayed_3db.setFrameShape(QtWidgets.QFrame.Shape.StyledPanel)
self.frame_toggleplayed_3db.setFrameShadow(QtWidgets.QFrame.Shadow.Raised)
self.frame_toggleplayed_3db.setObjectName("frame_toggleplayed_3db")
self.verticalLayout_6 = QtWidgets.QVBoxLayout(self.frame_toggleplayed_3db)
self.verticalLayout_6.setObjectName("verticalLayout_6")
self.btnDrop3db = QtWidgets.QPushButton(parent=self.frame_toggleplayed_3db)
self.btnDrop3db.setMinimumSize(QtCore.QSize(132, 41))
self.btnDrop3db.setMaximumSize(QtCore.QSize(164, 16777215))
self.btnDrop3db.setCheckable(True)
self.btnDrop3db.setObjectName("btnDrop3db")
self.verticalLayout_6.addWidget(self.btnDrop3db)
self.btnHidePlayed = QtWidgets.QPushButton(parent=self.frame_toggleplayed_3db)
self.btnHidePlayed.setMinimumSize(QtCore.QSize(132, 41))
self.btnHidePlayed.setMaximumSize(QtCore.QSize(164, 16777215))
self.btnHidePlayed.setCheckable(True)
self.btnHidePlayed.setObjectName("btnHidePlayed")
self.verticalLayout_6.addWidget(self.btnHidePlayed)
self.horizontalLayout.addWidget(self.frame_toggleplayed_3db)
self.frame_fade = QtWidgets.QFrame(parent=self.InfoFooterFrame)
self.frame_fade.setMinimumSize(QtCore.QSize(152, 112))
self.frame_fade.setStyleSheet("")
self.frame_fade.setFrameShape(QtWidgets.QFrame.Shape.StyledPanel)
self.frame_fade.setFrameShadow(QtWidgets.QFrame.Shadow.Raised)
self.frame_fade.setObjectName("frame_fade")
self.verticalLayout_2 = QtWidgets.QVBoxLayout(self.frame_fade)
self.verticalLayout_2.setObjectName("verticalLayout_2")
self.label_4 = QtWidgets.QLabel(parent=self.frame_fade)
self.label_4.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
self.label_4.setObjectName("label_4")
self.verticalLayout_2.addWidget(self.label_4)
self.label_fade_timer = QtWidgets.QLabel(parent=self.frame_fade)
font = QtGui.QFont()
font.setFamily("FreeSans")
font.setPointSize(40)
font.setBold(False)
font.setWeight(50)
self.label_fade_timer.setFont(font)
self.label_fade_timer.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
self.label_fade_timer.setObjectName("label_fade_timer")
self.verticalLayout_2.addWidget(self.label_fade_timer)
self.horizontalLayout.addWidget(self.frame_fade)
self.frame_silent = QtWidgets.QFrame(parent=self.InfoFooterFrame)
self.frame_silent.setMinimumSize(QtCore.QSize(152, 112))
self.frame_silent.setStyleSheet("")
self.frame_silent.setFrameShape(QtWidgets.QFrame.Shape.StyledPanel)
self.frame_silent.setFrameShadow(QtWidgets.QFrame.Shadow.Raised)
self.frame_silent.setObjectName("frame_silent")
self.verticalLayout_7 = QtWidgets.QVBoxLayout(self.frame_silent)
self.verticalLayout_7.setObjectName("verticalLayout_7")
self.label_5 = QtWidgets.QLabel(parent=self.frame_silent)
self.label_5.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
self.label_5.setObjectName("label_5")
self.verticalLayout_7.addWidget(self.label_5)
self.label_silent_timer = QtWidgets.QLabel(parent=self.frame_silent)
font = QtGui.QFont()
font.setFamily("FreeSans")
font.setPointSize(40)
font.setBold(False)
font.setWeight(50)
self.label_silent_timer.setFont(font)
self.label_silent_timer.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
self.label_silent_timer.setObjectName("label_silent_timer")
self.verticalLayout_7.addWidget(self.label_silent_timer)
self.horizontalLayout.addWidget(self.frame_silent)
self.widgetFadeVolume = PlotWidget(parent=self.InfoFooterFrame)
sizePolicy = QtWidgets.QSizePolicy(
QtWidgets.QSizePolicy.Policy.Preferred,
QtWidgets.QSizePolicy.Policy.Preferred,
)
sizePolicy.setHorizontalStretch(1)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(
self.widgetFadeVolume.sizePolicy().hasHeightForWidth()
)
self.widgetFadeVolume.setSizePolicy(sizePolicy)
self.widgetFadeVolume.setMinimumSize(QtCore.QSize(0, 0))
self.widgetFadeVolume.setObjectName("widgetFadeVolume")
self.horizontalLayout.addWidget(self.widgetFadeVolume)
self.frame = QtWidgets.QFrame(parent=self.InfoFooterFrame)
self.frame.setMinimumSize(QtCore.QSize(151, 0))
self.frame.setMaximumSize(QtCore.QSize(151, 112))
self.frame.setFrameShape(QtWidgets.QFrame.Shape.StyledPanel)
self.frame.setFrameShadow(QtWidgets.QFrame.Shadow.Raised)
self.frame.setObjectName("frame")
self.verticalLayout_5 = QtWidgets.QVBoxLayout(self.frame)
self.verticalLayout_5.setObjectName("verticalLayout_5")
self.btnFade = QtWidgets.QPushButton(parent=self.frame)
self.btnFade.setMinimumSize(QtCore.QSize(132, 32))
self.btnFade.setMaximumSize(QtCore.QSize(164, 16777215))
icon3 = QtGui.QIcon()
icon3.addPixmap(
QtGui.QPixmap(":/icons/fade"),
QtGui.QIcon.Mode.Normal,
QtGui.QIcon.State.Off,
)
self.btnFade.setIcon(icon3)
self.btnFade.setIconSize(QtCore.QSize(30, 30))
self.btnFade.setObjectName("btnFade")
self.verticalLayout_5.addWidget(self.btnFade)
self.btnStop = QtWidgets.QPushButton(parent=self.frame)
self.btnStop.setMinimumSize(QtCore.QSize(0, 36))
icon4 = QtGui.QIcon()
icon4.addPixmap(
QtGui.QPixmap(":/icons/stopsign"),
QtGui.QIcon.Mode.Normal,
QtGui.QIcon.State.Off,
)
self.btnStop.setIcon(icon4)
self.btnStop.setObjectName("btnStop")
self.verticalLayout_5.addWidget(self.btnStop)
self.horizontalLayout.addWidget(self.frame)
self.horizontalLayout_2.addWidget(self.InfoFooterFrame)
self.retranslateUi(FooterSection)
QtCore.QMetaObject.connectSlotsByName(FooterSection)
def retranslateUi(self, FooterSection):
_translate = QtCore.QCoreApplication.translate
FooterSection.setWindowTitle(_translate("FooterSection", "Form"))
self.btnPreview.setText(_translate("FooterSection", " Preview"))
self.btnPreviewStart.setText(_translate("FooterSection", "<<"))
self.btnPreviewEnd.setText(_translate("FooterSection", ">>"))
self.btnPreviewBack.setText(_translate("FooterSection", "<"))
self.btnPreviewFwd.setText(_translate("FooterSection", ">"))
self.label_7.setText(_translate("FooterSection", "Intro"))
self.label_intro_timer.setText(_translate("FooterSection", "0:0"))
self.btnDrop3db.setText(_translate("FooterSection", "-3dB to talk"))
self.btnHidePlayed.setText(_translate("FooterSection", "Hide played"))
self.label_4.setText(_translate("FooterSection", "Fade"))
self.label_fade_timer.setText(_translate("FooterSection", "00:00"))
self.label_5.setText(_translate("FooterSection", "Silent"))
self.label_silent_timer.setText(_translate("FooterSection", "00:00"))
self.btnFade.setText(_translate("FooterSection", " Fade"))
self.btnStop.setText(_translate("FooterSection", " Stop"))
from pyqtgraph import PlotWidget # type: ignore

View File

@ -1,314 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>HeaderSection</class>
<widget class="QWidget" name="HeaderSection">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>1273</width>
<height>179</height>
</rect>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<layout class="QGridLayout" name="gridLayout">
<item row="0" column="0">
<layout class="QHBoxLayout" name="horizontalLayout_3">
<item>
<layout class="QVBoxLayout" name="verticalLayout_3">
<item>
<widget class="QLabel" name="previous_track_2">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="maximumSize">
<size>
<width>230</width>
<height>16777215</height>
</size>
</property>
<property name="font">
<font>
<family>Sans</family>
<pointsize>20</pointsize>
</font>
</property>
<property name="styleSheet">
<string notr="true">background-color: #f8d7da;
border: 1px solid rgb(85, 87, 83);</string>
</property>
<property name="text">
<string>Last track:</string>
</property>
<property name="alignment">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="current_track_2">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="maximumSize">
<size>
<width>230</width>
<height>16777215</height>
</size>
</property>
<property name="font">
<font>
<family>Sans</family>
<pointsize>20</pointsize>
</font>
</property>
<property name="styleSheet">
<string notr="true">background-color: #d4edda;
border: 1px solid rgb(85, 87, 83);</string>
</property>
<property name="text">
<string>Current track:</string>
</property>
<property name="alignment">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="next_track_2">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="maximumSize">
<size>
<width>230</width>
<height>16777215</height>
</size>
</property>
<property name="font">
<font>
<family>Sans</family>
<pointsize>20</pointsize>
</font>
</property>
<property name="styleSheet">
<string notr="true">background-color: #fff3cd;
border: 1px solid rgb(85, 87, 83);</string>
</property>
<property name="text">
<string>Next track:</string>
</property>
<property name="alignment">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property>
</widget>
</item>
</layout>
</item>
<item>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QLabel" name="hdrPreviousTrack">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>0</width>
<height>0</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>16777215</width>
<height>16777215</height>
</size>
</property>
<property name="font">
<font>
<family>Sans</family>
<pointsize>20</pointsize>
</font>
</property>
<property name="styleSheet">
<string notr="true">background-color: #f8d7da;
border: 1px solid rgb(85, 87, 83);</string>
</property>
<property name="text">
<string/>
</property>
<property name="wordWrap">
<bool>false</bool>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="hdrCurrentTrack">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="font">
<font>
<pointsize>20</pointsize>
</font>
</property>
<property name="styleSheet">
<string notr="true">background-color: #d4edda;
border: 1px solid rgb(85, 87, 83);
text-align: left;
padding-left: 8px;
</string>
</property>
<property name="text">
<string/>
</property>
<property name="flat">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="hdrNextTrack">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="font">
<font>
<pointsize>20</pointsize>
</font>
</property>
<property name="styleSheet">
<string notr="true">background-color: #fff3cd;
border: 1px solid rgb(85, 87, 83);
text-align: left;
padding-left: 8px;</string>
</property>
<property name="text">
<string/>
</property>
<property name="flat">
<bool>true</bool>
</property>
</widget>
</item>
</layout>
</item>
<item>
<widget class="QFrame" name="frame_2">
<property name="minimumSize">
<size>
<width>0</width>
<height>131</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>230</width>
<height>131</height>
</size>
</property>
<property name="frameShape">
<enum>QFrame::StyledPanel</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Raised</enum>
</property>
<layout class="QVBoxLayout" name="verticalLayout_10">
<item>
<widget class="QLabel" name="lblTOD">
<property name="minimumSize">
<size>
<width>208</width>
<height>0</height>
</size>
</property>
<property name="font">
<font>
<pointsize>35</pointsize>
</font>
</property>
<property name="text">
<string>00:00:00</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="label_elapsed_timer">
<property name="font">
<font>
<family>FreeSans</family>
<pointsize>18</pointsize>
<weight>50</weight>
<bold>false</bold>
</font>
</property>
<property name="styleSheet">
<string notr="true">color: black;</string>
</property>
<property name="text">
<string>00:00 / 00:00</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
</layout>
</widget>
</item>
</layout>
</item>
<item row="1" column="0">
<widget class="QFrame" name="frame_4">
<property name="minimumSize">
<size>
<width>0</width>
<height>16</height>
</size>
</property>
<property name="autoFillBackground">
<bool>false</bool>
</property>
<property name="styleSheet">
<string notr="true">background-color: rgb(154, 153, 150)</string>
</property>
<property name="frameShape">
<enum>QFrame::StyledPanel</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Raised</enum>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>

View File

@ -1,178 +0,0 @@
# Form implementation generated from reading ui file 'app/ui/main_window_header.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_HeaderSection(object):
def setupUi(self, HeaderSection):
HeaderSection.setObjectName("HeaderSection")
HeaderSection.resize(1273, 179)
self.horizontalLayout = QtWidgets.QHBoxLayout(HeaderSection)
self.horizontalLayout.setObjectName("horizontalLayout")
self.gridLayout = QtWidgets.QGridLayout()
self.gridLayout.setObjectName("gridLayout")
self.horizontalLayout_3 = QtWidgets.QHBoxLayout()
self.horizontalLayout_3.setObjectName("horizontalLayout_3")
self.verticalLayout_3 = QtWidgets.QVBoxLayout()
self.verticalLayout_3.setObjectName("verticalLayout_3")
self.previous_track_2 = QtWidgets.QLabel(parent=HeaderSection)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Preferred, QtWidgets.QSizePolicy.Policy.Preferred)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.previous_track_2.sizePolicy().hasHeightForWidth())
self.previous_track_2.setSizePolicy(sizePolicy)
self.previous_track_2.setMaximumSize(QtCore.QSize(230, 16777215))
font = QtGui.QFont()
font.setFamily("Sans")
font.setPointSize(20)
self.previous_track_2.setFont(font)
self.previous_track_2.setStyleSheet("background-color: #f8d7da;\n"
"border: 1px solid rgb(85, 87, 83);")
self.previous_track_2.setAlignment(QtCore.Qt.AlignmentFlag.AlignRight|QtCore.Qt.AlignmentFlag.AlignTrailing|QtCore.Qt.AlignmentFlag.AlignVCenter)
self.previous_track_2.setObjectName("previous_track_2")
self.verticalLayout_3.addWidget(self.previous_track_2)
self.current_track_2 = QtWidgets.QLabel(parent=HeaderSection)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Preferred, QtWidgets.QSizePolicy.Policy.Preferred)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.current_track_2.sizePolicy().hasHeightForWidth())
self.current_track_2.setSizePolicy(sizePolicy)
self.current_track_2.setMaximumSize(QtCore.QSize(230, 16777215))
font = QtGui.QFont()
font.setFamily("Sans")
font.setPointSize(20)
self.current_track_2.setFont(font)
self.current_track_2.setStyleSheet("background-color: #d4edda;\n"
"border: 1px solid rgb(85, 87, 83);")
self.current_track_2.setAlignment(QtCore.Qt.AlignmentFlag.AlignRight|QtCore.Qt.AlignmentFlag.AlignTrailing|QtCore.Qt.AlignmentFlag.AlignVCenter)
self.current_track_2.setObjectName("current_track_2")
self.verticalLayout_3.addWidget(self.current_track_2)
self.next_track_2 = QtWidgets.QLabel(parent=HeaderSection)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Preferred, QtWidgets.QSizePolicy.Policy.Preferred)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.next_track_2.sizePolicy().hasHeightForWidth())
self.next_track_2.setSizePolicy(sizePolicy)
self.next_track_2.setMaximumSize(QtCore.QSize(230, 16777215))
font = QtGui.QFont()
font.setFamily("Sans")
font.setPointSize(20)
self.next_track_2.setFont(font)
self.next_track_2.setStyleSheet("background-color: #fff3cd;\n"
"border: 1px solid rgb(85, 87, 83);")
self.next_track_2.setAlignment(QtCore.Qt.AlignmentFlag.AlignRight|QtCore.Qt.AlignmentFlag.AlignTrailing|QtCore.Qt.AlignmentFlag.AlignVCenter)
self.next_track_2.setObjectName("next_track_2")
self.verticalLayout_3.addWidget(self.next_track_2)
self.horizontalLayout_3.addLayout(self.verticalLayout_3)
self.verticalLayout = QtWidgets.QVBoxLayout()
self.verticalLayout.setObjectName("verticalLayout")
self.hdrPreviousTrack = QtWidgets.QLabel(parent=HeaderSection)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Preferred, QtWidgets.QSizePolicy.Policy.Preferred)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.hdrPreviousTrack.sizePolicy().hasHeightForWidth())
self.hdrPreviousTrack.setSizePolicy(sizePolicy)
self.hdrPreviousTrack.setMinimumSize(QtCore.QSize(0, 0))
self.hdrPreviousTrack.setMaximumSize(QtCore.QSize(16777215, 16777215))
font = QtGui.QFont()
font.setFamily("Sans")
font.setPointSize(20)
self.hdrPreviousTrack.setFont(font)
self.hdrPreviousTrack.setStyleSheet("background-color: #f8d7da;\n"
"border: 1px solid rgb(85, 87, 83);")
self.hdrPreviousTrack.setText("")
self.hdrPreviousTrack.setWordWrap(False)
self.hdrPreviousTrack.setObjectName("hdrPreviousTrack")
self.verticalLayout.addWidget(self.hdrPreviousTrack)
self.hdrCurrentTrack = QtWidgets.QPushButton(parent=HeaderSection)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Preferred, QtWidgets.QSizePolicy.Policy.Preferred)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.hdrCurrentTrack.sizePolicy().hasHeightForWidth())
self.hdrCurrentTrack.setSizePolicy(sizePolicy)
font = QtGui.QFont()
font.setPointSize(20)
self.hdrCurrentTrack.setFont(font)
self.hdrCurrentTrack.setStyleSheet("background-color: #d4edda;\n"
"border: 1px solid rgb(85, 87, 83);\n"
"text-align: left;\n"
"padding-left: 8px;\n"
"")
self.hdrCurrentTrack.setText("")
self.hdrCurrentTrack.setFlat(True)
self.hdrCurrentTrack.setObjectName("hdrCurrentTrack")
self.verticalLayout.addWidget(self.hdrCurrentTrack)
self.hdrNextTrack = QtWidgets.QPushButton(parent=HeaderSection)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Preferred, QtWidgets.QSizePolicy.Policy.Preferred)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.hdrNextTrack.sizePolicy().hasHeightForWidth())
self.hdrNextTrack.setSizePolicy(sizePolicy)
font = QtGui.QFont()
font.setPointSize(20)
self.hdrNextTrack.setFont(font)
self.hdrNextTrack.setStyleSheet("background-color: #fff3cd;\n"
"border: 1px solid rgb(85, 87, 83);\n"
"text-align: left;\n"
"padding-left: 8px;")
self.hdrNextTrack.setText("")
self.hdrNextTrack.setFlat(True)
self.hdrNextTrack.setObjectName("hdrNextTrack")
self.verticalLayout.addWidget(self.hdrNextTrack)
self.horizontalLayout_3.addLayout(self.verticalLayout)
self.frame_2 = QtWidgets.QFrame(parent=HeaderSection)
self.frame_2.setMinimumSize(QtCore.QSize(0, 131))
self.frame_2.setMaximumSize(QtCore.QSize(230, 131))
self.frame_2.setFrameShape(QtWidgets.QFrame.Shape.StyledPanel)
self.frame_2.setFrameShadow(QtWidgets.QFrame.Shadow.Raised)
self.frame_2.setObjectName("frame_2")
self.verticalLayout_10 = QtWidgets.QVBoxLayout(self.frame_2)
self.verticalLayout_10.setObjectName("verticalLayout_10")
self.lblTOD = QtWidgets.QLabel(parent=self.frame_2)
self.lblTOD.setMinimumSize(QtCore.QSize(208, 0))
font = QtGui.QFont()
font.setPointSize(35)
self.lblTOD.setFont(font)
self.lblTOD.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
self.lblTOD.setObjectName("lblTOD")
self.verticalLayout_10.addWidget(self.lblTOD)
self.label_elapsed_timer = QtWidgets.QLabel(parent=self.frame_2)
font = QtGui.QFont()
font.setFamily("FreeSans")
font.setPointSize(18)
font.setBold(False)
font.setWeight(50)
self.label_elapsed_timer.setFont(font)
self.label_elapsed_timer.setStyleSheet("color: black;")
self.label_elapsed_timer.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
self.label_elapsed_timer.setObjectName("label_elapsed_timer")
self.verticalLayout_10.addWidget(self.label_elapsed_timer)
self.horizontalLayout_3.addWidget(self.frame_2)
self.gridLayout.addLayout(self.horizontalLayout_3, 0, 0, 1, 1)
self.frame_4 = QtWidgets.QFrame(parent=HeaderSection)
self.frame_4.setMinimumSize(QtCore.QSize(0, 16))
self.frame_4.setAutoFillBackground(False)
self.frame_4.setStyleSheet("background-color: rgb(154, 153, 150)")
self.frame_4.setFrameShape(QtWidgets.QFrame.Shape.StyledPanel)
self.frame_4.setFrameShadow(QtWidgets.QFrame.Shadow.Raised)
self.frame_4.setObjectName("frame_4")
self.gridLayout.addWidget(self.frame_4, 1, 0, 1, 1)
self.horizontalLayout.addLayout(self.gridLayout)
self.retranslateUi(HeaderSection)
QtCore.QMetaObject.connectSlotsByName(HeaderSection)
def retranslateUi(self, HeaderSection):
_translate = QtCore.QCoreApplication.translate
HeaderSection.setWindowTitle(_translate("HeaderSection", "Form"))
self.previous_track_2.setText(_translate("HeaderSection", "Last track:"))
self.current_track_2.setText(_translate("HeaderSection", "Current track:"))
self.next_track_2.setText(_translate("HeaderSection", "Next track:"))
self.lblTOD.setText(_translate("HeaderSection", "00:00:00"))
self.label_elapsed_timer.setText(_translate("HeaderSection", "00:00 / 00:00"))

View File

@ -1,42 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>PlaylistSection</class>
<widget class="QWidget" name="PlaylistSection">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>1249</width>
<height>538</height>
</rect>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<widget class="QSplitter" name="splitter">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<widget class="QTabWidget" name="tabPlaylist">
<property name="currentIndex">
<number>-1</number>
</property>
<property name="documentMode">
<bool>false</bool>
</property>
<property name="tabsClosable">
<bool>true</bool>
</property>
<property name="movable">
<bool>true</bool>
</property>
</widget>
</widget>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>

View File

@ -1,34 +0,0 @@
# Form implementation generated from reading ui file 'app/ui/main_window_playlist.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_PlaylistSection(object):
def setupUi(self, PlaylistSection):
PlaylistSection.setObjectName("PlaylistSection")
PlaylistSection.resize(1249, 499)
self.horizontalLayout = QtWidgets.QHBoxLayout(PlaylistSection)
self.horizontalLayout.setObjectName("horizontalLayout")
self.splitter = QtWidgets.QSplitter(parent=PlaylistSection)
self.splitter.setOrientation(QtCore.Qt.Orientation.Vertical)
self.splitter.setObjectName("splitter")
self.tabPlaylist = QtWidgets.QTabWidget(parent=self.splitter)
self.tabPlaylist.setDocumentMode(False)
self.tabPlaylist.setTabsClosable(True)
self.tabPlaylist.setMovable(True)
self.tabPlaylist.setObjectName("tabPlaylist")
self.horizontalLayout.addWidget(self.splitter)
self.retranslateUi(PlaylistSection)
self.tabPlaylist.setCurrentIndex(-1)
QtCore.QMetaObject.connectSlotsByName(PlaylistSection)
def retranslateUi(self, PlaylistSection):
_translate = QtCore.QCoreApplication.translate
PlaylistSection.setWindowTitle(_translate("PlaylistSection", "Form"))

844
app/ui/main_window_ui.py Normal file
View File

@ -0,0 +1,844 @@
# Form implementation generated from reading ui file 'app/ui/main_window.ui'
#
# Created by: PyQt6 UI code generator 6.7.0
#
# 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_MainWindow(object):
def setupUi(self, MainWindow):
MainWindow.setObjectName("MainWindow")
MainWindow.resize(1280, 857)
MainWindow.setMinimumSize(QtCore.QSize(1280, 0))
icon = QtGui.QIcon()
icon.addPixmap(
QtGui.QPixmap(":/icons/musicmuster"),
QtGui.QIcon.Mode.Normal,
QtGui.QIcon.State.Off,
)
MainWindow.setWindowIcon(icon)
MainWindow.setStyleSheet("")
self.centralwidget = QtWidgets.QWidget(parent=MainWindow)
self.centralwidget.setObjectName("centralwidget")
self.gridLayout_4 = QtWidgets.QGridLayout(self.centralwidget)
self.gridLayout_4.setObjectName("gridLayout_4")
self.horizontalLayout_3 = QtWidgets.QHBoxLayout()
self.horizontalLayout_3.setObjectName("horizontalLayout_3")
self.verticalLayout_3 = QtWidgets.QVBoxLayout()
self.verticalLayout_3.setObjectName("verticalLayout_3")
self.previous_track_2 = QtWidgets.QLabel(parent=self.centralwidget)
sizePolicy = QtWidgets.QSizePolicy(
QtWidgets.QSizePolicy.Policy.Preferred,
QtWidgets.QSizePolicy.Policy.Preferred,
)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(
self.previous_track_2.sizePolicy().hasHeightForWidth()
)
self.previous_track_2.setSizePolicy(sizePolicy)
self.previous_track_2.setMaximumSize(QtCore.QSize(230, 16777215))
font = QtGui.QFont()
font.setFamily("Sans")
font.setPointSize(20)
self.previous_track_2.setFont(font)
self.previous_track_2.setStyleSheet(
"background-color: #f8d7da;\n" "border: 1px solid rgb(85, 87, 83);"
)
self.previous_track_2.setAlignment(
QtCore.Qt.AlignmentFlag.AlignRight
| QtCore.Qt.AlignmentFlag.AlignTrailing
| QtCore.Qt.AlignmentFlag.AlignVCenter
)
self.previous_track_2.setObjectName("previous_track_2")
self.verticalLayout_3.addWidget(self.previous_track_2)
self.current_track_2 = QtWidgets.QLabel(parent=self.centralwidget)
sizePolicy = QtWidgets.QSizePolicy(
QtWidgets.QSizePolicy.Policy.Preferred,
QtWidgets.QSizePolicy.Policy.Preferred,
)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(
self.current_track_2.sizePolicy().hasHeightForWidth()
)
self.current_track_2.setSizePolicy(sizePolicy)
self.current_track_2.setMaximumSize(QtCore.QSize(230, 16777215))
font = QtGui.QFont()
font.setFamily("Sans")
font.setPointSize(20)
self.current_track_2.setFont(font)
self.current_track_2.setStyleSheet(
"background-color: #d4edda;\n" "border: 1px solid rgb(85, 87, 83);"
)
self.current_track_2.setAlignment(
QtCore.Qt.AlignmentFlag.AlignRight
| QtCore.Qt.AlignmentFlag.AlignTrailing
| QtCore.Qt.AlignmentFlag.AlignVCenter
)
self.current_track_2.setObjectName("current_track_2")
self.verticalLayout_3.addWidget(self.current_track_2)
self.next_track_2 = QtWidgets.QLabel(parent=self.centralwidget)
sizePolicy = QtWidgets.QSizePolicy(
QtWidgets.QSizePolicy.Policy.Preferred,
QtWidgets.QSizePolicy.Policy.Preferred,
)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.next_track_2.sizePolicy().hasHeightForWidth())
self.next_track_2.setSizePolicy(sizePolicy)
self.next_track_2.setMaximumSize(QtCore.QSize(230, 16777215))
font = QtGui.QFont()
font.setFamily("Sans")
font.setPointSize(20)
self.next_track_2.setFont(font)
self.next_track_2.setStyleSheet(
"background-color: #fff3cd;\n" "border: 1px solid rgb(85, 87, 83);"
)
self.next_track_2.setAlignment(
QtCore.Qt.AlignmentFlag.AlignRight
| QtCore.Qt.AlignmentFlag.AlignTrailing
| QtCore.Qt.AlignmentFlag.AlignVCenter
)
self.next_track_2.setObjectName("next_track_2")
self.verticalLayout_3.addWidget(self.next_track_2)
self.horizontalLayout_3.addLayout(self.verticalLayout_3)
self.verticalLayout = QtWidgets.QVBoxLayout()
self.verticalLayout.setObjectName("verticalLayout")
self.hdrPreviousTrack = QtWidgets.QLabel(parent=self.centralwidget)
sizePolicy = QtWidgets.QSizePolicy(
QtWidgets.QSizePolicy.Policy.Preferred,
QtWidgets.QSizePolicy.Policy.Preferred,
)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(
self.hdrPreviousTrack.sizePolicy().hasHeightForWidth()
)
self.hdrPreviousTrack.setSizePolicy(sizePolicy)
self.hdrPreviousTrack.setMinimumSize(QtCore.QSize(0, 0))
self.hdrPreviousTrack.setMaximumSize(QtCore.QSize(16777215, 16777215))
font = QtGui.QFont()
font.setFamily("Sans")
font.setPointSize(20)
self.hdrPreviousTrack.setFont(font)
self.hdrPreviousTrack.setStyleSheet(
"background-color: #f8d7da;\n" "border: 1px solid rgb(85, 87, 83);"
)
self.hdrPreviousTrack.setText("")
self.hdrPreviousTrack.setWordWrap(False)
self.hdrPreviousTrack.setObjectName("hdrPreviousTrack")
self.verticalLayout.addWidget(self.hdrPreviousTrack)
self.hdrCurrentTrack = QtWidgets.QPushButton(parent=self.centralwidget)
sizePolicy = QtWidgets.QSizePolicy(
QtWidgets.QSizePolicy.Policy.Preferred,
QtWidgets.QSizePolicy.Policy.Preferred,
)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(
self.hdrCurrentTrack.sizePolicy().hasHeightForWidth()
)
self.hdrCurrentTrack.setSizePolicy(sizePolicy)
font = QtGui.QFont()
font.setPointSize(20)
self.hdrCurrentTrack.setFont(font)
self.hdrCurrentTrack.setStyleSheet(
"background-color: #d4edda;\n"
"border: 1px solid rgb(85, 87, 83);\n"
"text-align: left;\n"
"padding-left: 8px;\n"
""
)
self.hdrCurrentTrack.setText("")
self.hdrCurrentTrack.setFlat(True)
self.hdrCurrentTrack.setObjectName("hdrCurrentTrack")
self.verticalLayout.addWidget(self.hdrCurrentTrack)
self.hdrNextTrack = QtWidgets.QPushButton(parent=self.centralwidget)
sizePolicy = QtWidgets.QSizePolicy(
QtWidgets.QSizePolicy.Policy.Preferred,
QtWidgets.QSizePolicy.Policy.Preferred,
)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.hdrNextTrack.sizePolicy().hasHeightForWidth())
self.hdrNextTrack.setSizePolicy(sizePolicy)
font = QtGui.QFont()
font.setPointSize(20)
self.hdrNextTrack.setFont(font)
self.hdrNextTrack.setStyleSheet(
"background-color: #fff3cd;\n"
"border: 1px solid rgb(85, 87, 83);\n"
"text-align: left;\n"
"padding-left: 8px;"
)
self.hdrNextTrack.setText("")
self.hdrNextTrack.setFlat(True)
self.hdrNextTrack.setObjectName("hdrNextTrack")
self.verticalLayout.addWidget(self.hdrNextTrack)
self.horizontalLayout_3.addLayout(self.verticalLayout)
self.frame_2 = QtWidgets.QFrame(parent=self.centralwidget)
self.frame_2.setMinimumSize(QtCore.QSize(0, 131))
self.frame_2.setMaximumSize(QtCore.QSize(230, 131))
self.frame_2.setFrameShape(QtWidgets.QFrame.Shape.StyledPanel)
self.frame_2.setFrameShadow(QtWidgets.QFrame.Shadow.Raised)
self.frame_2.setObjectName("frame_2")
self.verticalLayout_10 = QtWidgets.QVBoxLayout(self.frame_2)
self.verticalLayout_10.setObjectName("verticalLayout_10")
self.lblTOD = QtWidgets.QLabel(parent=self.frame_2)
self.lblTOD.setMinimumSize(QtCore.QSize(208, 0))
font = QtGui.QFont()
font.setPointSize(35)
self.lblTOD.setFont(font)
self.lblTOD.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
self.lblTOD.setObjectName("lblTOD")
self.verticalLayout_10.addWidget(self.lblTOD)
self.label_elapsed_timer = QtWidgets.QLabel(parent=self.frame_2)
font = QtGui.QFont()
font.setFamily("FreeSans")
font.setPointSize(18)
font.setBold(False)
font.setWeight(50)
self.label_elapsed_timer.setFont(font)
self.label_elapsed_timer.setStyleSheet("color: black;")
self.label_elapsed_timer.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
self.label_elapsed_timer.setObjectName("label_elapsed_timer")
self.verticalLayout_10.addWidget(self.label_elapsed_timer)
self.horizontalLayout_3.addWidget(self.frame_2)
self.gridLayout_4.addLayout(self.horizontalLayout_3, 0, 0, 1, 1)
self.frame_4 = QtWidgets.QFrame(parent=self.centralwidget)
self.frame_4.setMinimumSize(QtCore.QSize(0, 16))
self.frame_4.setAutoFillBackground(False)
self.frame_4.setStyleSheet("background-color: rgb(154, 153, 150)")
self.frame_4.setFrameShape(QtWidgets.QFrame.Shape.StyledPanel)
self.frame_4.setFrameShadow(QtWidgets.QFrame.Shadow.Raised)
self.frame_4.setObjectName("frame_4")
self.gridLayout_4.addWidget(self.frame_4, 1, 0, 1, 1)
self.cartsWidget = QtWidgets.QWidget(parent=self.centralwidget)
self.cartsWidget.setObjectName("cartsWidget")
self.horizontalLayout_Carts = QtWidgets.QHBoxLayout(self.cartsWidget)
self.horizontalLayout_Carts.setObjectName("horizontalLayout_Carts")
spacerItem = QtWidgets.QSpacerItem(
40,
20,
QtWidgets.QSizePolicy.Policy.Expanding,
QtWidgets.QSizePolicy.Policy.Minimum,
)
self.horizontalLayout_Carts.addItem(spacerItem)
self.gridLayout_4.addWidget(self.cartsWidget, 2, 0, 1, 1)
self.frame_6 = QtWidgets.QFrame(parent=self.centralwidget)
self.frame_6.setMinimumSize(QtCore.QSize(0, 16))
self.frame_6.setAutoFillBackground(False)
self.frame_6.setStyleSheet("background-color: rgb(154, 153, 150)")
self.frame_6.setFrameShape(QtWidgets.QFrame.Shape.StyledPanel)
self.frame_6.setFrameShadow(QtWidgets.QFrame.Shadow.Raised)
self.frame_6.setObjectName("frame_6")
self.gridLayout_4.addWidget(self.frame_6, 3, 0, 1, 1)
self.splitter = QtWidgets.QSplitter(parent=self.centralwidget)
self.splitter.setOrientation(QtCore.Qt.Orientation.Vertical)
self.splitter.setObjectName("splitter")
self.tabPlaylist = QtWidgets.QTabWidget(parent=self.splitter)
self.tabPlaylist.setDocumentMode(False)
self.tabPlaylist.setTabsClosable(True)
self.tabPlaylist.setMovable(True)
self.tabPlaylist.setObjectName("tabPlaylist")
self.tabInfolist = InfoTabs(parent=self.splitter)
self.tabInfolist.setDocumentMode(False)
self.tabInfolist.setTabsClosable(True)
self.tabInfolist.setMovable(True)
self.tabInfolist.setTabBarAutoHide(False)
self.tabInfolist.setObjectName("tabInfolist")
self.gridLayout_4.addWidget(self.splitter, 4, 0, 1, 1)
self.InfoFooterFrame = QtWidgets.QFrame(parent=self.centralwidget)
self.InfoFooterFrame.setMaximumSize(QtCore.QSize(16777215, 16777215))
self.InfoFooterFrame.setStyleSheet("background-color: rgb(192, 191, 188)")
self.InfoFooterFrame.setFrameShape(QtWidgets.QFrame.Shape.StyledPanel)
self.InfoFooterFrame.setFrameShadow(QtWidgets.QFrame.Shadow.Raised)
self.InfoFooterFrame.setObjectName("InfoFooterFrame")
self.horizontalLayout = QtWidgets.QHBoxLayout(self.InfoFooterFrame)
self.horizontalLayout.setObjectName("horizontalLayout")
self.FadeStopInfoFrame = QtWidgets.QFrame(parent=self.InfoFooterFrame)
self.FadeStopInfoFrame.setMinimumSize(QtCore.QSize(152, 112))
self.FadeStopInfoFrame.setMaximumSize(QtCore.QSize(184, 16777215))
self.FadeStopInfoFrame.setFrameShape(QtWidgets.QFrame.Shape.StyledPanel)
self.FadeStopInfoFrame.setFrameShadow(QtWidgets.QFrame.Shadow.Raised)
self.FadeStopInfoFrame.setObjectName("FadeStopInfoFrame")
self.verticalLayout_4 = QtWidgets.QVBoxLayout(self.FadeStopInfoFrame)
self.verticalLayout_4.setObjectName("verticalLayout_4")
self.btnPreview = QtWidgets.QPushButton(parent=self.FadeStopInfoFrame)
self.btnPreview.setMinimumSize(QtCore.QSize(132, 41))
icon1 = QtGui.QIcon()
icon1.addPixmap(
QtGui.QPixmap(":/icons/headphones"),
QtGui.QIcon.Mode.Normal,
QtGui.QIcon.State.Off,
)
self.btnPreview.setIcon(icon1)
self.btnPreview.setIconSize(QtCore.QSize(30, 30))
self.btnPreview.setCheckable(True)
self.btnPreview.setObjectName("btnPreview")
self.verticalLayout_4.addWidget(self.btnPreview)
self.groupBoxIntroControls = QtWidgets.QGroupBox(parent=self.FadeStopInfoFrame)
self.groupBoxIntroControls.setMinimumSize(QtCore.QSize(132, 46))
self.groupBoxIntroControls.setMaximumSize(QtCore.QSize(132, 46))
self.groupBoxIntroControls.setTitle("")
self.groupBoxIntroControls.setObjectName("groupBoxIntroControls")
self.btnPreviewStart = QtWidgets.QPushButton(parent=self.groupBoxIntroControls)
self.btnPreviewStart.setGeometry(QtCore.QRect(0, 0, 44, 23))
self.btnPreviewStart.setMinimumSize(QtCore.QSize(44, 23))
self.btnPreviewStart.setMaximumSize(QtCore.QSize(44, 23))
self.btnPreviewStart.setObjectName("btnPreviewStart")
self.btnPreviewArm = QtWidgets.QPushButton(parent=self.groupBoxIntroControls)
self.btnPreviewArm.setGeometry(QtCore.QRect(44, 0, 44, 23))
self.btnPreviewArm.setMinimumSize(QtCore.QSize(44, 23))
self.btnPreviewArm.setMaximumSize(QtCore.QSize(44, 23))
self.btnPreviewArm.setText("")
icon2 = QtGui.QIcon()
icon2.addPixmap(
QtGui.QPixmap(":/icons/record-button.png"),
QtGui.QIcon.Mode.Normal,
QtGui.QIcon.State.Off,
)
icon2.addPixmap(
QtGui.QPixmap(":/icons/record-red-button.png"),
QtGui.QIcon.Mode.Normal,
QtGui.QIcon.State.On,
)
self.btnPreviewArm.setIcon(icon2)
self.btnPreviewArm.setCheckable(True)
self.btnPreviewArm.setObjectName("btnPreviewArm")
self.btnPreviewEnd = QtWidgets.QPushButton(parent=self.groupBoxIntroControls)
self.btnPreviewEnd.setGeometry(QtCore.QRect(88, 0, 44, 23))
self.btnPreviewEnd.setMinimumSize(QtCore.QSize(44, 23))
self.btnPreviewEnd.setMaximumSize(QtCore.QSize(44, 23))
self.btnPreviewEnd.setObjectName("btnPreviewEnd")
self.btnPreviewBack = QtWidgets.QPushButton(parent=self.groupBoxIntroControls)
self.btnPreviewBack.setGeometry(QtCore.QRect(0, 23, 44, 23))
self.btnPreviewBack.setMinimumSize(QtCore.QSize(44, 23))
self.btnPreviewBack.setMaximumSize(QtCore.QSize(44, 23))
self.btnPreviewBack.setObjectName("btnPreviewBack")
self.btnPreviewMark = QtWidgets.QPushButton(parent=self.groupBoxIntroControls)
self.btnPreviewMark.setEnabled(False)
self.btnPreviewMark.setGeometry(QtCore.QRect(44, 23, 44, 23))
self.btnPreviewMark.setMinimumSize(QtCore.QSize(44, 23))
self.btnPreviewMark.setMaximumSize(QtCore.QSize(44, 23))
self.btnPreviewMark.setText("")
icon3 = QtGui.QIcon()
icon3.addPixmap(
QtGui.QPixmap(":/icons/star.png"),
QtGui.QIcon.Mode.Normal,
QtGui.QIcon.State.On,
)
icon3.addPixmap(
QtGui.QPixmap(":/icons/star_empty.png"),
QtGui.QIcon.Mode.Disabled,
QtGui.QIcon.State.Off,
)
self.btnPreviewMark.setIcon(icon3)
self.btnPreviewMark.setObjectName("btnPreviewMark")
self.btnPreviewFwd = QtWidgets.QPushButton(parent=self.groupBoxIntroControls)
self.btnPreviewFwd.setGeometry(QtCore.QRect(88, 23, 44, 23))
self.btnPreviewFwd.setMinimumSize(QtCore.QSize(44, 23))
self.btnPreviewFwd.setMaximumSize(QtCore.QSize(44, 23))
self.btnPreviewFwd.setObjectName("btnPreviewFwd")
self.verticalLayout_4.addWidget(self.groupBoxIntroControls)
self.horizontalLayout.addWidget(self.FadeStopInfoFrame)
self.frame_intro = QtWidgets.QFrame(parent=self.InfoFooterFrame)
self.frame_intro.setMinimumSize(QtCore.QSize(152, 112))
self.frame_intro.setStyleSheet("")
self.frame_intro.setFrameShape(QtWidgets.QFrame.Shape.StyledPanel)
self.frame_intro.setFrameShadow(QtWidgets.QFrame.Shadow.Raised)
self.frame_intro.setObjectName("frame_intro")
self.verticalLayout_9 = QtWidgets.QVBoxLayout(self.frame_intro)
self.verticalLayout_9.setObjectName("verticalLayout_9")
self.label_7 = QtWidgets.QLabel(parent=self.frame_intro)
self.label_7.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
self.label_7.setObjectName("label_7")
self.verticalLayout_9.addWidget(self.label_7)
self.label_intro_timer = QtWidgets.QLabel(parent=self.frame_intro)
font = QtGui.QFont()
font.setFamily("FreeSans")
font.setPointSize(40)
font.setBold(False)
font.setWeight(50)
self.label_intro_timer.setFont(font)
self.label_intro_timer.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
self.label_intro_timer.setObjectName("label_intro_timer")
self.verticalLayout_9.addWidget(self.label_intro_timer)
self.horizontalLayout.addWidget(self.frame_intro)
self.frame_toggleplayed_3db = QtWidgets.QFrame(parent=self.InfoFooterFrame)
self.frame_toggleplayed_3db.setMinimumSize(QtCore.QSize(152, 112))
self.frame_toggleplayed_3db.setMaximumSize(QtCore.QSize(184, 16777215))
self.frame_toggleplayed_3db.setFrameShape(QtWidgets.QFrame.Shape.StyledPanel)
self.frame_toggleplayed_3db.setFrameShadow(QtWidgets.QFrame.Shadow.Raised)
self.frame_toggleplayed_3db.setObjectName("frame_toggleplayed_3db")
self.verticalLayout_6 = QtWidgets.QVBoxLayout(self.frame_toggleplayed_3db)
self.verticalLayout_6.setObjectName("verticalLayout_6")
self.btnDrop3db = QtWidgets.QPushButton(parent=self.frame_toggleplayed_3db)
self.btnDrop3db.setMinimumSize(QtCore.QSize(132, 41))
self.btnDrop3db.setMaximumSize(QtCore.QSize(164, 16777215))
self.btnDrop3db.setCheckable(True)
self.btnDrop3db.setObjectName("btnDrop3db")
self.verticalLayout_6.addWidget(self.btnDrop3db)
self.btnHidePlayed = QtWidgets.QPushButton(parent=self.frame_toggleplayed_3db)
self.btnHidePlayed.setMinimumSize(QtCore.QSize(132, 41))
self.btnHidePlayed.setMaximumSize(QtCore.QSize(164, 16777215))
self.btnHidePlayed.setCheckable(True)
self.btnHidePlayed.setObjectName("btnHidePlayed")
self.verticalLayout_6.addWidget(self.btnHidePlayed)
self.horizontalLayout.addWidget(self.frame_toggleplayed_3db)
self.frame_fade = QtWidgets.QFrame(parent=self.InfoFooterFrame)
self.frame_fade.setMinimumSize(QtCore.QSize(152, 112))
self.frame_fade.setStyleSheet("")
self.frame_fade.setFrameShape(QtWidgets.QFrame.Shape.StyledPanel)
self.frame_fade.setFrameShadow(QtWidgets.QFrame.Shadow.Raised)
self.frame_fade.setObjectName("frame_fade")
self.verticalLayout_2 = QtWidgets.QVBoxLayout(self.frame_fade)
self.verticalLayout_2.setObjectName("verticalLayout_2")
self.label_4 = QtWidgets.QLabel(parent=self.frame_fade)
self.label_4.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
self.label_4.setObjectName("label_4")
self.verticalLayout_2.addWidget(self.label_4)
self.label_fade_timer = QtWidgets.QLabel(parent=self.frame_fade)
font = QtGui.QFont()
font.setFamily("FreeSans")
font.setPointSize(40)
font.setBold(False)
font.setWeight(50)
self.label_fade_timer.setFont(font)
self.label_fade_timer.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
self.label_fade_timer.setObjectName("label_fade_timer")
self.verticalLayout_2.addWidget(self.label_fade_timer)
self.horizontalLayout.addWidget(self.frame_fade)
self.frame_silent = QtWidgets.QFrame(parent=self.InfoFooterFrame)
self.frame_silent.setMinimumSize(QtCore.QSize(152, 112))
self.frame_silent.setStyleSheet("")
self.frame_silent.setFrameShape(QtWidgets.QFrame.Shape.StyledPanel)
self.frame_silent.setFrameShadow(QtWidgets.QFrame.Shadow.Raised)
self.frame_silent.setObjectName("frame_silent")
self.verticalLayout_7 = QtWidgets.QVBoxLayout(self.frame_silent)
self.verticalLayout_7.setObjectName("verticalLayout_7")
self.label_5 = QtWidgets.QLabel(parent=self.frame_silent)
self.label_5.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
self.label_5.setObjectName("label_5")
self.verticalLayout_7.addWidget(self.label_5)
self.label_silent_timer = QtWidgets.QLabel(parent=self.frame_silent)
font = QtGui.QFont()
font.setFamily("FreeSans")
font.setPointSize(40)
font.setBold(False)
font.setWeight(50)
self.label_silent_timer.setFont(font)
self.label_silent_timer.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
self.label_silent_timer.setObjectName("label_silent_timer")
self.verticalLayout_7.addWidget(self.label_silent_timer)
self.horizontalLayout.addWidget(self.frame_silent)
self.widgetFadeVolume = PlotWidget(parent=self.InfoFooterFrame)
sizePolicy = QtWidgets.QSizePolicy(
QtWidgets.QSizePolicy.Policy.Preferred,
QtWidgets.QSizePolicy.Policy.Preferred,
)
sizePolicy.setHorizontalStretch(1)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(
self.widgetFadeVolume.sizePolicy().hasHeightForWidth()
)
self.widgetFadeVolume.setSizePolicy(sizePolicy)
self.widgetFadeVolume.setMinimumSize(QtCore.QSize(0, 0))
self.widgetFadeVolume.setObjectName("widgetFadeVolume")
self.horizontalLayout.addWidget(self.widgetFadeVolume)
self.frame = QtWidgets.QFrame(parent=self.InfoFooterFrame)
self.frame.setMinimumSize(QtCore.QSize(151, 0))
self.frame.setMaximumSize(QtCore.QSize(151, 112))
self.frame.setFrameShape(QtWidgets.QFrame.Shape.StyledPanel)
self.frame.setFrameShadow(QtWidgets.QFrame.Shadow.Raised)
self.frame.setObjectName("frame")
self.verticalLayout_5 = QtWidgets.QVBoxLayout(self.frame)
self.verticalLayout_5.setObjectName("verticalLayout_5")
self.btnFade = QtWidgets.QPushButton(parent=self.frame)
self.btnFade.setMinimumSize(QtCore.QSize(132, 32))
self.btnFade.setMaximumSize(QtCore.QSize(164, 16777215))
icon4 = QtGui.QIcon()
icon4.addPixmap(
QtGui.QPixmap(":/icons/fade"),
QtGui.QIcon.Mode.Normal,
QtGui.QIcon.State.Off,
)
self.btnFade.setIcon(icon4)
self.btnFade.setIconSize(QtCore.QSize(30, 30))
self.btnFade.setObjectName("btnFade")
self.verticalLayout_5.addWidget(self.btnFade)
self.btnStop = QtWidgets.QPushButton(parent=self.frame)
self.btnStop.setMinimumSize(QtCore.QSize(0, 36))
icon5 = QtGui.QIcon()
icon5.addPixmap(
QtGui.QPixmap(":/icons/stopsign"),
QtGui.QIcon.Mode.Normal,
QtGui.QIcon.State.Off,
)
self.btnStop.setIcon(icon5)
self.btnStop.setObjectName("btnStop")
self.verticalLayout_5.addWidget(self.btnStop)
self.horizontalLayout.addWidget(self.frame)
self.gridLayout_4.addWidget(self.InfoFooterFrame, 5, 0, 1, 1)
MainWindow.setCentralWidget(self.centralwidget)
self.menubar = QtWidgets.QMenuBar(parent=MainWindow)
self.menubar.setGeometry(QtCore.QRect(0, 0, 1280, 29))
self.menubar.setObjectName("menubar")
self.menuFile = QtWidgets.QMenu(parent=self.menubar)
self.menuFile.setObjectName("menuFile")
self.menuPlaylist = QtWidgets.QMenu(parent=self.menubar)
self.menuPlaylist.setObjectName("menuPlaylist")
self.menuSearc_h = QtWidgets.QMenu(parent=self.menubar)
self.menuSearc_h.setObjectName("menuSearc_h")
self.menuHelp = QtWidgets.QMenu(parent=self.menubar)
self.menuHelp.setObjectName("menuHelp")
MainWindow.setMenuBar(self.menubar)
self.statusbar = QtWidgets.QStatusBar(parent=MainWindow)
self.statusbar.setEnabled(True)
self.statusbar.setStyleSheet("background-color: rgb(211, 215, 207);")
self.statusbar.setObjectName("statusbar")
MainWindow.setStatusBar(self.statusbar)
self.actionPlay_next = QtGui.QAction(parent=MainWindow)
icon6 = QtGui.QIcon()
icon6.addPixmap(
QtGui.QPixmap("app/ui/../../../../../../.designer/backup/icon-play.png"),
QtGui.QIcon.Mode.Normal,
QtGui.QIcon.State.Off,
)
self.actionPlay_next.setIcon(icon6)
self.actionPlay_next.setObjectName("actionPlay_next")
self.actionSkipToNext = QtGui.QAction(parent=MainWindow)
icon7 = QtGui.QIcon()
icon7.addPixmap(
QtGui.QPixmap(":/icons/next"),
QtGui.QIcon.Mode.Normal,
QtGui.QIcon.State.Off,
)
self.actionSkipToNext.setIcon(icon7)
self.actionSkipToNext.setObjectName("actionSkipToNext")
self.actionInsertTrack = QtGui.QAction(parent=MainWindow)
icon8 = QtGui.QIcon()
icon8.addPixmap(
QtGui.QPixmap(
"app/ui/../../../../../../.designer/backup/icon_search_database.png"
),
QtGui.QIcon.Mode.Normal,
QtGui.QIcon.State.Off,
)
self.actionInsertTrack.setIcon(icon8)
self.actionInsertTrack.setObjectName("actionInsertTrack")
self.actionAdd_file = QtGui.QAction(parent=MainWindow)
icon9 = QtGui.QIcon()
icon9.addPixmap(
QtGui.QPixmap(
"app/ui/../../../../../../.designer/backup/icon_open_file.png"
),
QtGui.QIcon.Mode.Normal,
QtGui.QIcon.State.Off,
)
self.actionAdd_file.setIcon(icon9)
self.actionAdd_file.setObjectName("actionAdd_file")
self.actionFade = QtGui.QAction(parent=MainWindow)
icon10 = QtGui.QIcon()
icon10.addPixmap(
QtGui.QPixmap("app/ui/../../../../../../.designer/backup/icon-fade.png"),
QtGui.QIcon.Mode.Normal,
QtGui.QIcon.State.Off,
)
self.actionFade.setIcon(icon10)
self.actionFade.setObjectName("actionFade")
self.actionStop = QtGui.QAction(parent=MainWindow)
icon11 = QtGui.QIcon()
icon11.addPixmap(
QtGui.QPixmap(":/icons/stop"),
QtGui.QIcon.Mode.Normal,
QtGui.QIcon.State.Off,
)
self.actionStop.setIcon(icon11)
self.actionStop.setObjectName("actionStop")
self.action_Clear_selection = QtGui.QAction(parent=MainWindow)
self.action_Clear_selection.setObjectName("action_Clear_selection")
self.action_Resume_previous = QtGui.QAction(parent=MainWindow)
icon12 = QtGui.QIcon()
icon12.addPixmap(
QtGui.QPixmap(":/icons/previous"),
QtGui.QIcon.Mode.Normal,
QtGui.QIcon.State.Off,
)
self.action_Resume_previous.setIcon(icon12)
self.action_Resume_previous.setObjectName("action_Resume_previous")
self.actionE_xit = QtGui.QAction(parent=MainWindow)
self.actionE_xit.setObjectName("actionE_xit")
self.actionTest = QtGui.QAction(parent=MainWindow)
self.actionTest.setObjectName("actionTest")
self.actionOpenPlaylist = QtGui.QAction(parent=MainWindow)
self.actionOpenPlaylist.setObjectName("actionOpenPlaylist")
self.actionNewPlaylist = QtGui.QAction(parent=MainWindow)
self.actionNewPlaylist.setObjectName("actionNewPlaylist")
self.actionTestFunction = QtGui.QAction(parent=MainWindow)
self.actionTestFunction.setObjectName("actionTestFunction")
self.actionSkipToFade = QtGui.QAction(parent=MainWindow)
self.actionSkipToFade.setObjectName("actionSkipToFade")
self.actionSkipToEnd = QtGui.QAction(parent=MainWindow)
self.actionSkipToEnd.setObjectName("actionSkipToEnd")
self.actionClosePlaylist = QtGui.QAction(parent=MainWindow)
self.actionClosePlaylist.setEnabled(True)
self.actionClosePlaylist.setObjectName("actionClosePlaylist")
self.actionRenamePlaylist = QtGui.QAction(parent=MainWindow)
self.actionRenamePlaylist.setEnabled(True)
self.actionRenamePlaylist.setObjectName("actionRenamePlaylist")
self.actionDeletePlaylist = QtGui.QAction(parent=MainWindow)
self.actionDeletePlaylist.setEnabled(True)
self.actionDeletePlaylist.setObjectName("actionDeletePlaylist")
self.actionMoveSelected = QtGui.QAction(parent=MainWindow)
self.actionMoveSelected.setObjectName("actionMoveSelected")
self.actionExport_playlist = QtGui.QAction(parent=MainWindow)
self.actionExport_playlist.setObjectName("actionExport_playlist")
self.actionSetNext = QtGui.QAction(parent=MainWindow)
self.actionSetNext.setObjectName("actionSetNext")
self.actionSelect_next_track = QtGui.QAction(parent=MainWindow)
self.actionSelect_next_track.setObjectName("actionSelect_next_track")
self.actionSelect_previous_track = QtGui.QAction(parent=MainWindow)
self.actionSelect_previous_track.setObjectName("actionSelect_previous_track")
self.actionSelect_played_tracks = QtGui.QAction(parent=MainWindow)
self.actionSelect_played_tracks.setObjectName("actionSelect_played_tracks")
self.actionMoveUnplayed = QtGui.QAction(parent=MainWindow)
self.actionMoveUnplayed.setObjectName("actionMoveUnplayed")
self.actionAdd_note = QtGui.QAction(parent=MainWindow)
self.actionAdd_note.setObjectName("actionAdd_note")
self.actionEnable_controls = QtGui.QAction(parent=MainWindow)
self.actionEnable_controls.setObjectName("actionEnable_controls")
self.actionImport = QtGui.QAction(parent=MainWindow)
self.actionImport.setObjectName("actionImport")
self.actionDownload_CSV_of_played_tracks = QtGui.QAction(parent=MainWindow)
self.actionDownload_CSV_of_played_tracks.setObjectName(
"actionDownload_CSV_of_played_tracks"
)
self.actionSearch = QtGui.QAction(parent=MainWindow)
self.actionSearch.setObjectName("actionSearch")
self.actionInsertSectionHeader = QtGui.QAction(parent=MainWindow)
self.actionInsertSectionHeader.setObjectName("actionInsertSectionHeader")
self.actionRemove = QtGui.QAction(parent=MainWindow)
self.actionRemove.setObjectName("actionRemove")
self.actionFind_next = QtGui.QAction(parent=MainWindow)
self.actionFind_next.setObjectName("actionFind_next")
self.actionFind_previous = QtGui.QAction(parent=MainWindow)
self.actionFind_previous.setObjectName("actionFind_previous")
self.action_About = QtGui.QAction(parent=MainWindow)
self.action_About.setObjectName("action_About")
self.actionSave_as_template = QtGui.QAction(parent=MainWindow)
self.actionSave_as_template.setObjectName("actionSave_as_template")
self.actionNew_from_template = QtGui.QAction(parent=MainWindow)
self.actionNew_from_template.setObjectName("actionNew_from_template")
self.actionDebug = QtGui.QAction(parent=MainWindow)
self.actionDebug.setObjectName("actionDebug")
self.actionAdd_cart = QtGui.QAction(parent=MainWindow)
self.actionAdd_cart.setObjectName("actionAdd_cart")
self.actionMark_for_moving = QtGui.QAction(parent=MainWindow)
self.actionMark_for_moving.setObjectName("actionMark_for_moving")
self.actionPaste = QtGui.QAction(parent=MainWindow)
self.actionPaste.setObjectName("actionPaste")
self.actionResume = QtGui.QAction(parent=MainWindow)
self.actionResume.setObjectName("actionResume")
self.actionSearch_title_in_Wikipedia = QtGui.QAction(parent=MainWindow)
self.actionSearch_title_in_Wikipedia.setObjectName(
"actionSearch_title_in_Wikipedia"
)
self.actionSearch_title_in_Songfacts = QtGui.QAction(parent=MainWindow)
self.actionSearch_title_in_Songfacts.setObjectName(
"actionSearch_title_in_Songfacts"
)
self.actionSelect_duplicate_rows = QtGui.QAction(parent=MainWindow)
self.actionSelect_duplicate_rows.setObjectName("actionSelect_duplicate_rows")
self.actionReplace_files = QtGui.QAction(parent=MainWindow)
self.actionReplace_files.setObjectName("actionReplace_files")
self.menuFile.addAction(self.actionNewPlaylist)
self.menuFile.addAction(self.actionNew_from_template)
self.menuFile.addAction(self.actionOpenPlaylist)
self.menuFile.addAction(self.actionClosePlaylist)
self.menuFile.addAction(self.actionRenamePlaylist)
self.menuFile.addAction(self.actionDeletePlaylist)
self.menuFile.addAction(self.actionExport_playlist)
self.menuFile.addSeparator()
self.menuFile.addAction(self.actionSelect_duplicate_rows)
self.menuFile.addSeparator()
self.menuFile.addAction(self.actionMoveSelected)
self.menuFile.addAction(self.actionMoveUnplayed)
self.menuFile.addAction(self.actionDownload_CSV_of_played_tracks)
self.menuFile.addAction(self.actionSave_as_template)
self.menuFile.addSeparator()
self.menuFile.addAction(self.actionReplace_files)
self.menuFile.addSeparator()
self.menuFile.addAction(self.actionE_xit)
self.menuPlaylist.addSeparator()
self.menuPlaylist.addAction(self.actionPlay_next)
self.menuPlaylist.addAction(self.actionFade)
self.menuPlaylist.addAction(self.actionStop)
self.menuPlaylist.addAction(self.actionResume)
self.menuPlaylist.addSeparator()
self.menuPlaylist.addAction(self.actionSkipToNext)
self.menuPlaylist.addSeparator()
self.menuPlaylist.addAction(self.actionInsertSectionHeader)
self.menuPlaylist.addAction(self.actionInsertTrack)
self.menuPlaylist.addAction(self.actionRemove)
self.menuPlaylist.addAction(self.actionImport)
self.menuPlaylist.addSeparator()
self.menuPlaylist.addAction(self.actionSetNext)
self.menuPlaylist.addAction(self.action_Clear_selection)
self.menuPlaylist.addSeparator()
self.menuPlaylist.addAction(self.actionMark_for_moving)
self.menuPlaylist.addAction(self.actionPaste)
self.menuSearc_h.addAction(self.actionSearch)
self.menuSearc_h.addSeparator()
self.menuSearc_h.addAction(self.actionSearch_title_in_Wikipedia)
self.menuSearc_h.addAction(self.actionSearch_title_in_Songfacts)
self.menuHelp.addAction(self.action_About)
self.menuHelp.addAction(self.actionDebug)
self.menubar.addAction(self.menuFile.menuAction())
self.menubar.addAction(self.menuPlaylist.menuAction())
self.menubar.addAction(self.menuSearc_h.menuAction())
self.menubar.addAction(self.menuHelp.menuAction())
self.retranslateUi(MainWindow)
self.tabPlaylist.setCurrentIndex(-1)
self.tabInfolist.setCurrentIndex(-1)
self.actionE_xit.triggered.connect(MainWindow.close) # type: ignore
QtCore.QMetaObject.connectSlotsByName(MainWindow)
def retranslateUi(self, MainWindow):
_translate = QtCore.QCoreApplication.translate
MainWindow.setWindowTitle(_translate("MainWindow", "Music Muster"))
self.previous_track_2.setText(_translate("MainWindow", "Last track:"))
self.current_track_2.setText(_translate("MainWindow", "Current track:"))
self.next_track_2.setText(_translate("MainWindow", "Next track:"))
self.lblTOD.setText(_translate("MainWindow", "00:00:00"))
self.label_elapsed_timer.setText(_translate("MainWindow", "00:00 / 00:00"))
self.btnPreview.setText(_translate("MainWindow", " Preview"))
self.btnPreviewStart.setText(_translate("MainWindow", "<<"))
self.btnPreviewEnd.setText(_translate("MainWindow", ">>"))
self.btnPreviewBack.setText(_translate("MainWindow", "<"))
self.btnPreviewFwd.setText(_translate("MainWindow", ">"))
self.label_7.setText(_translate("MainWindow", "Intro"))
self.label_intro_timer.setText(_translate("MainWindow", "0:0"))
self.btnDrop3db.setText(_translate("MainWindow", "-3dB to talk"))
self.btnHidePlayed.setText(_translate("MainWindow", "Hide played"))
self.label_4.setText(_translate("MainWindow", "Fade"))
self.label_fade_timer.setText(_translate("MainWindow", "00:00"))
self.label_5.setText(_translate("MainWindow", "Silent"))
self.label_silent_timer.setText(_translate("MainWindow", "00:00"))
self.btnFade.setText(_translate("MainWindow", " Fade"))
self.btnStop.setText(_translate("MainWindow", " Stop"))
self.menuFile.setTitle(_translate("MainWindow", "&Playlists"))
self.menuPlaylist.setTitle(_translate("MainWindow", "Sho&wtime"))
self.menuSearc_h.setTitle(_translate("MainWindow", "&Search"))
self.menuHelp.setTitle(_translate("MainWindow", "&Help"))
self.actionPlay_next.setText(_translate("MainWindow", "&Play next"))
self.actionPlay_next.setShortcut(_translate("MainWindow", "Return"))
self.actionSkipToNext.setText(_translate("MainWindow", "Skip to &next"))
self.actionSkipToNext.setShortcut(_translate("MainWindow", "Ctrl+Alt+Return"))
self.actionInsertTrack.setText(_translate("MainWindow", "Insert &track..."))
self.actionInsertTrack.setShortcut(_translate("MainWindow", "Ctrl+T"))
self.actionAdd_file.setText(_translate("MainWindow", "Add &file"))
self.actionAdd_file.setShortcut(_translate("MainWindow", "Ctrl+F"))
self.actionFade.setText(_translate("MainWindow", "F&ade"))
self.actionFade.setShortcut(_translate("MainWindow", "Ctrl+Z"))
self.actionStop.setText(_translate("MainWindow", "S&top"))
self.actionStop.setShortcut(_translate("MainWindow", "Ctrl+Alt+S"))
self.action_Clear_selection.setText(
_translate("MainWindow", "Clear &selection")
)
self.action_Clear_selection.setShortcut(_translate("MainWindow", "Esc"))
self.action_Resume_previous.setText(
_translate("MainWindow", "&Resume previous")
)
self.actionE_xit.setText(_translate("MainWindow", "E&xit"))
self.actionTest.setText(_translate("MainWindow", "&Test"))
self.actionOpenPlaylist.setText(_translate("MainWindow", "O&pen..."))
self.actionNewPlaylist.setText(_translate("MainWindow", "&New..."))
self.actionTestFunction.setText(_translate("MainWindow", "&Test function"))
self.actionSkipToFade.setText(
_translate("MainWindow", "&Skip to start of fade")
)
self.actionSkipToEnd.setText(_translate("MainWindow", "Skip to &end of track"))
self.actionClosePlaylist.setText(_translate("MainWindow", "&Close"))
self.actionRenamePlaylist.setText(_translate("MainWindow", "&Rename..."))
self.actionDeletePlaylist.setText(_translate("MainWindow", "Dele&te..."))
self.actionMoveSelected.setText(
_translate("MainWindow", "Mo&ve selected tracks to...")
)
self.actionExport_playlist.setText(_translate("MainWindow", "E&xport..."))
self.actionSetNext.setText(_translate("MainWindow", "Set &next"))
self.actionSetNext.setShortcut(_translate("MainWindow", "Ctrl+N"))
self.actionSelect_next_track.setText(
_translate("MainWindow", "Select next track")
)
self.actionSelect_next_track.setShortcut(_translate("MainWindow", "J"))
self.actionSelect_previous_track.setText(
_translate("MainWindow", "Select previous track")
)
self.actionSelect_previous_track.setShortcut(_translate("MainWindow", "K"))
self.actionSelect_played_tracks.setText(
_translate("MainWindow", "Select played tracks")
)
self.actionMoveUnplayed.setText(
_translate("MainWindow", "Move &unplayed tracks to...")
)
self.actionAdd_note.setText(_translate("MainWindow", "Add note..."))
self.actionAdd_note.setShortcut(_translate("MainWindow", "Ctrl+T"))
self.actionEnable_controls.setText(_translate("MainWindow", "Enable controls"))
self.actionImport.setText(_translate("MainWindow", "Import track..."))
self.actionImport.setShortcut(_translate("MainWindow", "Ctrl+Shift+I"))
self.actionDownload_CSV_of_played_tracks.setText(
_translate("MainWindow", "Download CSV of played tracks...")
)
self.actionSearch.setText(_translate("MainWindow", "Search..."))
self.actionSearch.setShortcut(_translate("MainWindow", "/"))
self.actionInsertSectionHeader.setText(
_translate("MainWindow", "Insert &section header...")
)
self.actionInsertSectionHeader.setShortcut(_translate("MainWindow", "Ctrl+H"))
self.actionRemove.setText(_translate("MainWindow", "&Remove track"))
self.actionFind_next.setText(_translate("MainWindow", "Find next"))
self.actionFind_next.setShortcut(_translate("MainWindow", "N"))
self.actionFind_previous.setText(_translate("MainWindow", "Find previous"))
self.actionFind_previous.setShortcut(_translate("MainWindow", "P"))
self.action_About.setText(_translate("MainWindow", "&About"))
self.actionSave_as_template.setText(
_translate("MainWindow", "Save as template...")
)
self.actionNew_from_template.setText(
_translate("MainWindow", "New from template...")
)
self.actionDebug.setText(_translate("MainWindow", "Debug"))
self.actionAdd_cart.setText(_translate("MainWindow", "Edit cart &1..."))
self.actionMark_for_moving.setText(_translate("MainWindow", "Mark for moving"))
self.actionMark_for_moving.setShortcut(_translate("MainWindow", "Ctrl+C"))
self.actionPaste.setText(_translate("MainWindow", "Paste"))
self.actionPaste.setShortcut(_translate("MainWindow", "Ctrl+V"))
self.actionResume.setText(_translate("MainWindow", "Resume"))
self.actionResume.setShortcut(_translate("MainWindow", "Ctrl+R"))
self.actionSearch_title_in_Wikipedia.setText(
_translate("MainWindow", "Search title in Wikipedia")
)
self.actionSearch_title_in_Wikipedia.setShortcut(
_translate("MainWindow", "Ctrl+W")
)
self.actionSearch_title_in_Songfacts.setText(
_translate("MainWindow", "Search title in Songfacts")
)
self.actionSearch_title_in_Songfacts.setShortcut(
_translate("MainWindow", "Ctrl+S")
)
self.actionSelect_duplicate_rows.setText(
_translate("MainWindow", "Select duplicate rows...")
)
self.actionReplace_files.setText(_translate("MainWindow", "Replace files..."))
from infotabs import InfoTabs
from pyqtgraph import PlotWidget

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

View File

@ -16,7 +16,7 @@ from log import log
from models import Tracks from models import Tracks
def check_db(session: Session) -> None: def check_db(session: Session):
""" """
Database consistency check. Database consistency check.
@ -84,7 +84,7 @@ def check_db(session: Session) -> None:
print("There were more paths than listed that were not found") print("There were more paths than listed that were not found")
def update_bitrates(session: Session) -> None: def update_bitrates(session: Session):
""" """
Update bitrates on all tracks in database Update bitrates on all tracks in database
""" """
@ -92,6 +92,6 @@ def update_bitrates(session: Session) -> None:
for track in Tracks.get_all(session): for track in Tracks.get_all(session):
try: try:
t = get_tags(track.path) t = get_tags(track.path)
track.bitrate = t.bitrate track.bitrate = t["bitrate"]
except FileNotFoundError: except FileNotFoundError:
continue continue

View File

@ -1,29 +0,0 @@
# Standard library imports
# PyQt imports
# Third party imports
import vlc # type: ignore
# App imports
class VLCManager:
"""
Singleton class to ensure we only ever have one vlc Instance
"""
__instance = None
def __init__(self) -> None:
if VLCManager.__instance is None:
self.vlc_instance = vlc.Instance()
VLCManager.__instance = self
else:
raise Exception("Attempted to create a second VLCManager instance")
@staticmethod
def get_instance() -> vlc.Instance:
if VLCManager.__instance is None:
VLCManager()
return VLCManager.__instance

View File

@ -23,8 +23,7 @@ def fade_point(audio_segment, fade_threshold=-12, chunk_size=10):
fade_threshold = max_vol fade_threshold = max_vol
while ( while (
audio_segment[trim_ms:trim_ms + chunk_size].dBFS < fade_threshold audio_segment[trim_ms:trim_ms + chunk_size].dBFS < fade_threshold
and trim_ms > 0 and trim_ms > 0): # noqa W503
): # noqa W503
trim_ms -= chunk_size trim_ms -= chunk_size
# if there is no trailing silence, return lenght of track (it's less # if there is no trailing silence, return lenght of track (it's less

View File

@ -1,125 +0,0 @@
#!/usr/bin/env python
import sys
from PyQt6.QtCore import (Qt, QAbstractTableModel, QModelIndex, QSortFilterProxyModel)
from PyQt6.QtWidgets import (QApplication, QMainWindow, QTableView, QLineEdit, QVBoxLayout, QWidget)
class CustomTableModel(QAbstractTableModel):
def __init__(self, data):
super().__init__()
self._data = data
def rowCount(self, parent=QModelIndex()):
return len(self._data)
def columnCount(self, parent=QModelIndex()):
return 2 # Row number and data
def data(self, index, role=Qt.ItemDataRole.DisplayRole):
if role == Qt.ItemDataRole.DisplayRole:
row, col = index.row(), index.column()
if col == 0:
return row + 1 # Row number (1-based index)
elif col == 1:
return self._data[row]
def setData(self, index, value, role=Qt.ItemDataRole.EditRole):
if role == Qt.ItemDataRole.EditRole and index.isValid():
self._data[index.row()] = value
self.dataChanged.emit(index, index, [Qt.ItemDataRole.EditRole])
return True
return False
def flags(self, index):
default_flags = Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled
if index.isValid():
return default_flags | Qt.ItemFlag.ItemIsDragEnabled | Qt.ItemFlag.ItemIsDropEnabled
return default_flags | Qt.ItemFlag.ItemIsDropEnabled
def removeRow(self, row):
self.beginRemoveRows(QModelIndex(), row, row)
self._data.pop(row)
self.endRemoveRows()
def insertRow(self, row, value):
self.beginInsertRows(QModelIndex(), row, row)
self._data.insert(row, value)
self.endInsertRows()
def moveRows(self, sourceParent, sourceRow, count, destinationParent, destinationRow):
if sourceRow < destinationRow:
destinationRow -= 1
self.beginMoveRows(sourceParent, sourceRow, sourceRow, destinationParent, destinationRow)
row_data = self._data.pop(sourceRow)
self._data.insert(destinationRow, row_data)
self.endMoveRows()
return True
class ProxyModel(QSortFilterProxyModel):
def __init__(self):
super().__init__()
self.filterString = ""
def setFilterString(self, text):
self.filterString = text
self.invalidateFilter()
def filterAcceptsRow(self, source_row, source_parent):
if self.filterString:
data = self.sourceModel().data(self.sourceModel().index(source_row, 1), Qt.ItemDataRole.DisplayRole)
return self.filterString in str(data)
return True
class TableView(QTableView):
def __init__(self, model):
super().__init__()
self.setModel(model)
self.setDragDropMode(QTableView.DragDropMode.InternalMove)
self.setSelectionBehavior(QTableView.SelectionBehavior.SelectRows)
self.setSortingEnabled(False)
self.setDragDropOverwriteMode(False)
def dropEvent(self, event):
source_index = self.indexAt(event.pos())
if not source_index.isValid():
return
destination_row = source_index.row()
dragged_row = self.currentIndex().row()
if dragged_row != destination_row:
self.model().sourceModel().moveRows(QModelIndex(), dragged_row, 1, QModelIndex(), destination_row)
super().dropEvent(event)
self.model().layoutChanged.emit() # Refresh model to update row numbers
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.data = ["dog", "hog", "don", "cat", "bat"]
self.baseModel = CustomTableModel(self.data)
self.proxyModel = ProxyModel()
self.proxyModel.setSourceModel(self.baseModel)
self.view = TableView(self.proxyModel)
self.filterLineEdit = QLineEdit()
self.filterLineEdit.setPlaceholderText("Filter by substring")
self.filterLineEdit.textChanged.connect(self.proxyModel.setFilterString)
layout = QVBoxLayout()
layout.addWidget(self.filterLineEdit)
layout.addWidget(self.view)
container = QWidget()
container.setLayout(layout)
self.setCentralWidget(container)
if __name__ == "__main__":
app = QApplication(sys.argv)
window = MainWindow()
window.show()
sys.exit(app.exec())

73
audacity_control.py Executable file
View File

@ -0,0 +1,73 @@
#!/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')

1
devnotes.txt Normal file
View File

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

View File

@ -1,7 +1,4 @@
<%! """${message}
import re
%>"""${message}
Revision ID: ${up_revision} Revision ID: ${up_revision}
Revises: ${down_revision | comma,n} Revises: ${down_revision | comma,n}
@ -19,27 +16,9 @@ branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)} depends_on = ${repr(depends_on)}
def upgrade(engine_name: str) -> None: def upgrade():
globals()["upgrade_%s" % engine_name]() ${upgrades if upgrades else "pass"}
def downgrade(engine_name: str) -> None: def downgrade():
globals()["downgrade_%s" % engine_name]() ${downgrades if downgrades else "pass"}
<%
db_names = config.get_main_option("databases")
%>
## generate an "upgrade_<xyz>() / downgrade_<xyz>()" function
## for each database name in the ini file.
% for db_name in re.split(r',\s*', db_names):
def upgrade_${db_name}() -> None:
${context.get("%s_upgrades" % db_name, "pass")}
def downgrade_${db_name}() -> None:
${context.get("%s_downgrades" % db_name, "pass")}
% endfor

View File

@ -1,58 +0,0 @@
"""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,32 @@
"""Add sort_column, deleted and query to playlists table
Revision ID: 07dcbe6c4f0e
Revises: 4a7b4ab3354f
Create Date: 2022-12-25 10:26:38.200941
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '07dcbe6c4f0e'
down_revision = '4a7b4ab3354f'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('playlists', sa.Column('sort_column', sa.Integer(), nullable=True))
op.add_column('playlists', sa.Column('query', sa.String(length=256), nullable=True))
op.add_column('playlists', sa.Column('deleted', sa.Boolean(), nullable=False))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('playlists', 'deleted')
op.drop_column('playlists', 'query')
op.drop_column('playlists', 'sort_column')
# ### end Alembic commands ###

View File

@ -0,0 +1,32 @@
"""Add 'played' column to playlist_rows
Revision ID: 0c604bf490f8
Revises: 29c0d7ffc741
Create Date: 2022-08-12 14:12:38.419845
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = "0c604bf490f8"
down_revision = "29c0d7ffc741"
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column("playlist_rows", sa.Column("played", sa.Boolean(), nullable=False))
op.drop_index("ix_tracks_lastplayed", table_name="tracks")
op.drop_column("tracks", "lastplayed")
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column("tracks", sa.Column("lastplayed", mysql.DATETIME(), nullable=True))
op.create_index("ix_tracks_lastplayed", "tracks", ["lastplayed"], unique=False)
op.drop_column("playlist_rows", "played")
# ### end Alembic commands ###

View File

@ -1,46 +0,0 @@
"""Remove mtime from Tracks
Revision ID: 164bd5ef3074
Revises: a524796269fa
Create Date: 2024-12-22 14:11:48.045995
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = '164bd5ef3074'
down_revision = 'a524796269fa'
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('tracks', schema=None) as batch_op:
batch_op.drop_index('ix_tracks_mtime')
batch_op.drop_column('mtime')
# ### end Alembic commands ###
def downgrade_() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('tracks', schema=None) as batch_op:
batch_op.add_column(sa.Column('mtime', mysql.FLOAT(), nullable=False))
batch_op.create_index('ix_tracks_mtime', ['mtime'], unique=False)
# ### end Alembic commands ###

View File

@ -0,0 +1,40 @@
"""Add columns to track table
Revision ID: 1bc727e5e87f
Revises: 52d82712d218
Create Date: 2021-03-22 22:43:40.458197
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = '1bc727e5e87f'
down_revision = '52d82712d218'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('tracks', sa.Column('duration', sa.Integer(), nullable=True))
op.add_column('tracks', sa.Column('fade_at', sa.Integer(), nullable=True))
op.add_column('tracks', sa.Column('silence_at', sa.Integer(), nullable=True))
op.add_column('tracks', sa.Column('start_gap', sa.Integer(), nullable=True))
op.drop_index('ix_tracks_length', table_name='tracks')
op.create_index(op.f('ix_tracks_duration'), 'tracks', ['duration'], unique=False)
op.drop_column('tracks', 'length')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('tracks', sa.Column('length', mysql.INTEGER(display_width=11), autoincrement=False, nullable=True))
op.drop_index(op.f('ix_tracks_duration'), table_name='tracks')
op.create_index('ix_tracks_length', 'tracks', ['length'], unique=False)
op.drop_column('tracks', 'start_gap')
op.drop_column('tracks', 'silence_at')
op.drop_column('tracks', 'fade_at')
op.drop_column('tracks', 'duration')
# ### end Alembic commands ###

View File

@ -0,0 +1,34 @@
"""Add constraint to playlist_tracks
Revision ID: 1c4048efee96
Revises: 52cbded98e7c
Create Date: 2022-03-29 19:26:27.378185
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = '1c4048efee96'
down_revision = '52cbded98e7c'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_unique_constraint('uniquerow', 'playlist_tracks', ['row', 'playlist_id'])
op.alter_column('playlists', 'loaded',
existing_type=mysql.TINYINT(display_width=1),
nullable=False)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('playlists', 'loaded',
existing_type=mysql.TINYINT(display_width=1),
nullable=True)
op.drop_constraint('uniquerow', 'playlist_tracks', type_='unique')
# ### end Alembic commands ###

View File

@ -0,0 +1,34 @@
"""Fixup playdates relationship
Revision ID: 269a002f989d
Revises: 9bf80ba3635f
Create Date: 2021-03-28 14:36:59.103846
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = '269a002f989d'
down_revision = '9bf80ba3635f'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('playdates', sa.Column('track_id', sa.Integer(), nullable=True))
op.create_foreign_key(None, 'playdates', 'tracks', ['track_id'], ['id'])
op.drop_constraint('tracks_ibfk_1', 'tracks', type_='foreignkey')
op.drop_column('tracks', 'playdates_id')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('tracks', sa.Column('playdates_id', mysql.INTEGER(display_width=11), autoincrement=False, nullable=True))
op.create_foreign_key('tracks_ibfk_1', 'tracks', 'playdates', ['playdates_id'], ['id'])
op.drop_constraint(None, 'playdates', type_='foreignkey')
op.drop_column('playdates', 'track_id')
# ### end Alembic commands ###

View File

@ -0,0 +1,24 @@
"""Drop uniquerow index on playlist_rows
Revision ID: 29c0d7ffc741
Revises: 3b063011ed67
Create Date: 2022-08-06 22:21:46.881378
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '29c0d7ffc741'
down_revision = '3b063011ed67'
branch_labels = None
depends_on = None
def upgrade():
op.drop_index('uniquerow', table_name='playlist_rows')
def downgrade():
op.create_index('uniquerow', 'playlist_rows', ['row_number', 'playlist_id'], unique=True)

View File

@ -0,0 +1,110 @@
"""add Tracks.intro column
Revision ID: 2caa3d37f211
Revises: 5bb2c572e1e5
Create Date: 2024-05-07 20:06:00.845979
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = '2caa3d37f211'
down_revision = '5bb2c572e1e5'
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('carts', schema=None) as batch_op:
batch_op.alter_column('name',
existing_type=mysql.VARCHAR(length=256),
nullable=False)
with op.batch_alter_table('notecolours', schema=None) as batch_op:
batch_op.alter_column('substring',
existing_type=mysql.VARCHAR(length=256),
nullable=False)
batch_op.alter_column('colour',
existing_type=mysql.VARCHAR(length=21),
nullable=False)
batch_op.alter_column('enabled',
existing_type=mysql.TINYINT(display_width=1),
nullable=False)
batch_op.alter_column('is_regex',
existing_type=mysql.TINYINT(display_width=1),
nullable=False)
batch_op.alter_column('is_casesensitive',
existing_type=mysql.TINYINT(display_width=1),
nullable=False)
with op.batch_alter_table('playdates', schema=None) as batch_op:
batch_op.alter_column('lastplayed',
existing_type=mysql.DATETIME(),
nullable=False)
batch_op.alter_column('track_id',
existing_type=mysql.INTEGER(display_width=11),
nullable=False)
with op.batch_alter_table('playlists', schema=None) as batch_op:
batch_op.drop_index('tab')
with op.batch_alter_table('tracks', schema=None) as batch_op:
batch_op.add_column(sa.Column('intro', sa.Integer(), nullable=True))
# ### end Alembic commands ###
def downgrade_() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('tracks', schema=None) as batch_op:
batch_op.drop_column('intro')
with op.batch_alter_table('playlists', schema=None) as batch_op:
batch_op.create_index('tab', ['tab'], unique=True)
with op.batch_alter_table('playdates', schema=None) as batch_op:
batch_op.alter_column('track_id',
existing_type=mysql.INTEGER(display_width=11),
nullable=True)
batch_op.alter_column('lastplayed',
existing_type=mysql.DATETIME(),
nullable=True)
with op.batch_alter_table('notecolours', schema=None) as batch_op:
batch_op.alter_column('is_casesensitive',
existing_type=mysql.TINYINT(display_width=1),
nullable=True)
batch_op.alter_column('is_regex',
existing_type=mysql.TINYINT(display_width=1),
nullable=True)
batch_op.alter_column('enabled',
existing_type=mysql.TINYINT(display_width=1),
nullable=True)
batch_op.alter_column('colour',
existing_type=mysql.VARCHAR(length=21),
nullable=True)
batch_op.alter_column('substring',
existing_type=mysql.VARCHAR(length=256),
nullable=True)
with op.batch_alter_table('carts', schema=None) as batch_op:
batch_op.alter_column('name',
existing_type=mysql.VARCHAR(length=256),
nullable=True)
# ### end Alembic commands ###

View File

@ -0,0 +1,30 @@
"""Add playlist dates and loaded
Revision ID: 2cc37d3cf07f
Revises: e3b04db5506f
Create Date: 2021-04-27 21:55:50.639406
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "2cc37d3cf07f"
down_revision = "e3b04db5506f"
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column("playlists", sa.Column("last_used", sa.DateTime(), nullable=True))
op.add_column("playlists", sa.Column("loaded", sa.Boolean(), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("playlists", "loaded")
op.drop_column("playlists", "last_used")
# ### end Alembic commands ###

View File

@ -1,52 +0,0 @@
"""Remove playlists.delete and implement Cascade deletes
Revision ID: 33c04e3c12c8
Revises: 164bd5ef3074
Create Date: 2024-12-29 17:56:00.627198
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = '33c04e3c12c8'
down_revision = '164bd5ef3074'
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('playlist_rows', schema=None) as batch_op:
batch_op.drop_constraint('playlist_rows_ibfk_3', type_='foreignkey')
batch_op.create_foreign_key('playlist_rows_ibfk_3', 'playlists', ['playlist_id'], ['id'], ondelete='CASCADE')
with op.batch_alter_table('playlists', schema=None) as batch_op:
batch_op.drop_column('deleted')
# ### 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.add_column(sa.Column('deleted', mysql.TINYINT(display_width=1), autoincrement=False, nullable=False))
with op.batch_alter_table('playlist_rows', schema=None) as batch_op:
batch_op.drop_constraint(None, type_='foreignkey')
batch_op.create_foreign_key(None, 'playlists', ['playlist_id'], ['id'])
# ### end Alembic commands ###

View File

@ -0,0 +1,72 @@
"""Migrate SQLA 2 and remove redundant columns
Revision ID: 3a53a9fb26ab
Revises: 07dcbe6c4f0e
Create Date: 2023-10-15 09:39:16.449419
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = '3a53a9fb26ab'
down_revision = '07dcbe6c4f0e'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('playlists', 'query')
op.drop_column('playlists', 'sort_column')
op.alter_column('tracks', 'title',
existing_type=mysql.VARCHAR(length=256),
nullable=False)
op.alter_column('tracks', 'artist',
existing_type=mysql.VARCHAR(length=256),
nullable=False)
op.alter_column('tracks', 'duration',
existing_type=mysql.INTEGER(display_width=11),
nullable=False)
op.alter_column('tracks', 'start_gap',
existing_type=mysql.INTEGER(display_width=11),
nullable=False)
op.alter_column('tracks', 'fade_at',
existing_type=mysql.INTEGER(display_width=11),
nullable=False)
op.alter_column('tracks', 'silence_at',
existing_type=mysql.INTEGER(display_width=11),
nullable=False)
op.alter_column('tracks', 'mtime',
existing_type=mysql.FLOAT(),
nullable=False)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('tracks', 'mtime',
existing_type=mysql.FLOAT(),
nullable=True)
op.alter_column('tracks', 'silence_at',
existing_type=mysql.INTEGER(display_width=11),
nullable=True)
op.alter_column('tracks', 'fade_at',
existing_type=mysql.INTEGER(display_width=11),
nullable=True)
op.alter_column('tracks', 'start_gap',
existing_type=mysql.INTEGER(display_width=11),
nullable=True)
op.alter_column('tracks', 'duration',
existing_type=mysql.INTEGER(display_width=11),
nullable=True)
op.alter_column('tracks', 'artist',
existing_type=mysql.VARCHAR(length=256),
nullable=True)
op.alter_column('tracks', 'title',
existing_type=mysql.VARCHAR(length=256),
nullable=True)
op.add_column('playlists', sa.Column('sort_column', mysql.INTEGER(display_width=11), autoincrement=False, nullable=True))
op.add_column('playlists', sa.Column('query', mysql.VARCHAR(length=256), nullable=True))
# ### end Alembic commands ###

View File

@ -0,0 +1,54 @@
"""schema changes for row notes
Revision ID: 3b063011ed67
Revises: 51f61433256f
Create Date: 2022-07-06 19:48:23.960471
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = '3b063011ed67'
down_revision = '51f61433256f'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('notes')
op.add_column('playlist_rows', sa.Column('note', sa.String(length=2048), nullable=True))
op.alter_column('playlist_rows', 'track_id',
existing_type=mysql.INTEGER(display_width=11),
nullable=True)
op.drop_index('uniquerow', table_name='playlist_rows')
op.drop_column('playlist_rows', 'text')
op.alter_column('playlist_rows', 'row', new_column_name='row_number',
existing_type=mysql.INTEGER(display_width=11),
nullable=False)
op.create_index('uniquerow', 'playlist_rows', ['row_number', 'playlist_id'], unique=True)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('playlist_rows', 'row_number', new_column_name='row',
existing_type=mysql.INTEGER(display_width=11),
nullable=False)
op.add_column('playlist_rows', sa.Column('text', mysql.VARCHAR(length=2048), nullable=True))
op.drop_index('uniquerow', table_name='playlist_rows')
op.create_index('uniquerow', 'playlist_rows', ['row', 'playlist_id'], unique=False)
op.drop_column('playlist_rows', 'note')
op.create_table('notes',
sa.Column('id', mysql.INTEGER(display_width=11), autoincrement=True, nullable=False),
sa.Column('playlist_id', mysql.INTEGER(display_width=11), autoincrement=False, nullable=True),
sa.Column('row', mysql.INTEGER(display_width=11), autoincrement=False, nullable=False),
sa.Column('note', mysql.VARCHAR(length=256), nullable=True),
sa.ForeignKeyConstraint(['playlist_id'], ['playlists.id'], name='notes_ibfk_1'),
sa.PrimaryKeyConstraint('id'),
mysql_default_charset='utf8mb4',
mysql_engine='InnoDB'
)
# ### end Alembic commands ###

View File

@ -0,0 +1,26 @@
"""Rename playlist_tracks to playlist_rows
Revision ID: 3f55ac7d80ad
Revises: 1c4048efee96
Create Date: 2022-07-04 20:51:59.874004
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '3f55ac7d80ad'
down_revision = '1c4048efee96'
branch_labels = None
depends_on = None
def upgrade():
# Rename so as not to lose content
op.rename_table('playlist_tracks', 'playlist_rows')
def downgrade():
# Rename so as not to lose content
op.rename_table('playlist_rows', 'playlist_tracks')

View File

@ -0,0 +1,32 @@
"""Record tab number for open playlists
Revision ID: 4a7b4ab3354f
Revises: 6730f03317df
Create Date: 2022-12-20 15:38:28.318280
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = '4a7b4ab3354f'
down_revision = '6730f03317df'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('playlists', sa.Column('tab', sa.Integer(), nullable=True))
op.create_unique_constraint(None, 'playlists', ['tab'])
op.drop_column('playlists', 'loaded')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('playlists', sa.Column('loaded', mysql.TINYINT(display_width=1), autoincrement=False, nullable=False))
op.drop_constraint(None, 'playlists', type_='unique')
op.drop_column('playlists', 'tab')
# ### end Alembic commands ###

View File

@ -1,47 +0,0 @@
"""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,34 @@
"""Increase settings.name len and add playlist_rows.notes
Revision ID: 51f61433256f
Revises: 3f55ac7d80ad
Create Date: 2022-07-04 21:21:39.830406
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = '51f61433256f'
down_revision = '3f55ac7d80ad'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('playlist_rows', sa.Column('text', sa.String(length=2048), nullable=True))
op.alter_column('playlists', 'loaded',
existing_type=mysql.TINYINT(display_width=1),
nullable=False)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('playlists', 'loaded',
existing_type=mysql.TINYINT(display_width=1),
nullable=True)
op.drop_column('playlist_rows', 'text')
# ### end Alembic commands ###

View File

@ -0,0 +1,30 @@
"""Update notecolours table
Revision ID: 52cbded98e7c
Revises: c55992d1fe5f
Create Date: 2022-02-06 12:34:30.099417
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = '52cbded98e7c'
down_revision = 'c55992d1fe5f'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('notecolours', sa.Column('colour', sa.String(length=21), nullable=True))
op.drop_column('notecolours', 'hexcolour')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('notecolours', sa.Column('hexcolour', mysql.VARCHAR(length=6), nullable=True))
op.drop_column('notecolours', 'colour')
# ### end Alembic commands ###

View File

@ -0,0 +1,24 @@
"""Initial
Revision ID: 52d82712d218
Revises:
Create Date: 2021-03-22 22:16:03.272827
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '52d82712d218'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
pass
def downgrade():
pass

View File

@ -0,0 +1,60 @@
"""Add 'open' field to Playlists
Revision ID: 5bb2c572e1e5
Revises: 3a53a9fb26ab
Create Date: 2023-11-18 14:19:02.643914
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = '5bb2c572e1e5'
down_revision = '3a53a9fb26ab'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('carts', 'duration',
existing_type=mysql.INTEGER(display_width=11),
nullable=True)
op.alter_column('carts', 'path',
existing_type=mysql.VARCHAR(length=2048),
nullable=True)
op.alter_column('carts', 'enabled',
existing_type=mysql.TINYINT(display_width=1),
nullable=True)
op.alter_column('playlist_rows', 'note',
existing_type=mysql.VARCHAR(length=2048),
nullable=False)
op.add_column('playlists', sa.Column('open', sa.Boolean(), nullable=False))
op.alter_column('settings', 'name',
existing_type=mysql.VARCHAR(length=32),
type_=sa.String(length=64),
existing_nullable=False)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('settings', 'name',
existing_type=sa.String(length=64),
type_=mysql.VARCHAR(length=32),
existing_nullable=False)
op.drop_column('playlists', 'open')
op.alter_column('playlist_rows', 'note',
existing_type=mysql.VARCHAR(length=2048),
nullable=True)
op.alter_column('carts', 'enabled',
existing_type=mysql.TINYINT(display_width=1),
nullable=False)
op.alter_column('carts', 'path',
existing_type=mysql.VARCHAR(length=2048),
nullable=False)
op.alter_column('carts', 'duration',
existing_type=mysql.INTEGER(display_width=11),
nullable=False)
# ### end Alembic commands ###

View File

@ -0,0 +1,41 @@
"""Add carts
Revision ID: 6730f03317df
Revises: b4f524e2140c
Create Date: 2022-09-13 19:41:33.181752
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '6730f03317df'
down_revision = 'b4f524e2140c'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('carts',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('cart_number', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=256), nullable=True),
sa.Column('duration', sa.Integer(), nullable=True),
sa.Column('path', sa.String(length=2048), nullable=True),
sa.Column('enabled', sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('cart_number')
)
op.create_index(op.f('ix_carts_duration'), 'carts', ['duration'], unique=False)
op.create_index(op.f('ix_carts_name'), 'carts', ['name'], unique=False)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_carts_name'), table_name='carts')
op.drop_index(op.f('ix_carts_duration'), table_name='carts')
op.drop_table('carts')
# ### end Alembic commands ###

View File

@ -1,75 +0,0 @@
"""Initial migration
Revision ID: 708a21f5c271
Revises:
Create Date: 2024-12-14 11:16:09.067598
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = '708a21f5c271'
down_revision = None
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('carts', schema=None) as batch_op:
batch_op.drop_index('cart_number')
batch_op.drop_index('ix_carts_duration')
batch_op.drop_index('ix_carts_name')
op.drop_table('carts')
with op.batch_alter_table('notecolours', schema=None) as batch_op:
batch_op.add_column(sa.Column('foreground', sa.String(length=21), nullable=True))
with op.batch_alter_table('playlist_rows', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_playlist_rows_playlist_id'), ['playlist_id'], unique=False)
batch_op.create_index(batch_op.f('ix_playlist_rows_row_number'), ['row_number'], unique=False)
# ### end Alembic commands ###
def downgrade_() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('playlist_rows', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_playlist_rows_row_number'))
batch_op.drop_index(batch_op.f('ix_playlist_rows_playlist_id'))
with op.batch_alter_table('notecolours', schema=None) as batch_op:
batch_op.drop_column('foreground')
op.create_table('carts',
sa.Column('id', mysql.INTEGER(display_width=11), autoincrement=True, nullable=False),
sa.Column('cart_number', mysql.INTEGER(display_width=11), autoincrement=False, nullable=False),
sa.Column('name', mysql.VARCHAR(length=256), nullable=False),
sa.Column('duration', mysql.INTEGER(display_width=11), autoincrement=False, nullable=True),
sa.Column('path', mysql.VARCHAR(length=2048), nullable=True),
sa.Column('enabled', mysql.TINYINT(display_width=1), autoincrement=False, nullable=True),
sa.PrimaryKeyConstraint('id'),
mysql_collate='utf8mb4_general_ci',
mysql_default_charset='utf8mb4',
mysql_engine='InnoDB'
)
with op.batch_alter_table('carts', schema=None) as batch_op:
batch_op.create_index('ix_carts_name', ['name'], unique=False)
batch_op.create_index('ix_carts_duration', ['duration'], unique=False)
batch_op.create_index('cart_number', ['cart_number'], unique=True)
# ### end Alembic commands ###

View File

@ -0,0 +1,34 @@
"""Add id to playlist association table
Revision ID: 9bf80ba3635f
Revises: f071129cbd93
Create Date: 2021-03-28 12:16:14.631579
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '9bf80ba3635f'
down_revision = 'f071129cbd93'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
conn = op.get_bind()
conn.execute(
"ALTER TABLE playlistracks ADD id INT PRIMARY KEY AUTO_INCREMENT FIRST"
)
conn.execute("RENAME TABLE playlistracks TO playlisttracks")
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
conn = op.get_bind()
conn.execute("ALTER TABLE playlistracks DROP id")
conn.execute("RENAME TABLE playlisttracks TO playlistracks")
# ### end Alembic commands ###

View File

@ -1,44 +0,0 @@
"""Add strip_substring to NoteColoursTable
Revision ID: a524796269fa
Revises: 708a21f5c271
Create Date: 2024-12-14 12:42:45.214707
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'a524796269fa'
down_revision = '708a21f5c271'
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))
# ### end Alembic commands ###
def downgrade_() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('notecolours', schema=None) as batch_op:
batch_op.drop_column('strip_substring')
# ### end Alembic commands ###

View File

@ -0,0 +1,36 @@
"""Add NoteColours table
Revision ID: a5aada49f2fc
Revises: 2cc37d3cf07f
Create Date: 2022-02-05 17:34:54.880473
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'a5aada49f2fc'
down_revision = '2cc37d3cf07f'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('notecolours',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('substring', sa.String(length=256), nullable=True),
sa.Column('hexcolour', sa.String(length=6), nullable=True),
sa.Column('enabled', sa.Boolean(), nullable=True),
sa.Column('is_regex', sa.Boolean(), nullable=True),
sa.Column('is_casesensitive', sa.Boolean(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('notecolours')
# ### end Alembic commands ###

View File

@ -1,46 +0,0 @@
"""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

@ -0,0 +1,37 @@
"""Add settings table
Revision ID: b0983648595e
Revises: 1bc727e5e87f
Create Date: 2021-03-26 13:33:41.994508
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "b0983648595e"
down_revision = "1bc727e5e87f"
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"settings",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("name", sa.String(length=32), nullable=False),
sa.Column("f_datetime", sa.DateTime(), nullable=True),
sa.Column("f_int", sa.Integer(), nullable=True),
sa.Column("f_string", sa.String(length=128), nullable=True),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("name"),
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table("settings")
# ### end Alembic commands ###

View File

@ -0,0 +1,32 @@
"""Add templates
Revision ID: b4f524e2140c
Revises: ed3100326c38
Create Date: 2022-10-01 13:30:21.663287
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'b4f524e2140c'
down_revision = 'ed3100326c38'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_foreign_key(None, 'playlist_rows', 'tracks', ['track_id'], ['id'])
op.create_foreign_key(None, 'playlist_rows', 'playlists', ['playlist_id'], ['id'])
op.add_column('playlists', sa.Column('is_template', sa.Boolean(), nullable=False))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('playlists', 'is_template')
op.drop_constraint(None, 'playlist_rows', type_='foreignkey')
op.drop_constraint(None, 'playlist_rows', type_='foreignkey')
# ### end Alembic commands ###

View File

@ -0,0 +1,32 @@
"""Add order to colours table
Revision ID: c55992d1fe5f
Revises: a5aada49f2fc
Create Date: 2022-02-05 21:28:36.391312
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'c55992d1fe5f'
down_revision = 'a5aada49f2fc'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('notecolours', sa.Column('order', sa.Integer(), nullable=True))
op.create_index(op.f('ix_notecolours_enabled'), 'notecolours', ['enabled'], unique=False)
op.create_index(op.f('ix_notecolours_order'), 'notecolours', ['order'], unique=False)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_notecolours_order'), table_name='notecolours')
op.drop_index(op.f('ix_notecolours_enabled'), table_name='notecolours')
op.drop_column('notecolours', 'order')
# ### end Alembic commands ###

View File

@ -0,0 +1,51 @@
"""Add structure for notes
Revision ID: e3b04db5506f
Revises: 269a002f989d
Create Date: 2021-04-05 16:33:50.117747
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = 'e3b04db5506f'
down_revision = '269a002f989d'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('notes',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('playlist_id', sa.Integer(), nullable=True),
sa.Column('row', sa.Integer(), nullable=False),
sa.Column('note', sa.String(length=256), nullable=True),
sa.ForeignKeyConstraint(['playlist_id'], ['playlists.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.add_column('playlisttracks', sa.Column('row', sa.Integer(), nullable=False))
op.alter_column('playlisttracks', 'playlist_id',
existing_type=mysql.INTEGER(display_width=11),
nullable=False)
op.alter_column('playlisttracks', 'track_id',
existing_type=mysql.INTEGER(display_width=11),
nullable=False)
op.drop_column('playlisttracks', 'sort')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('playlisttracks', sa.Column('sort', mysql.INTEGER(display_width=11), autoincrement=False, nullable=False))
op.alter_column('playlisttracks', 'track_id',
existing_type=mysql.INTEGER(display_width=11),
nullable=True)
op.alter_column('playlisttracks', 'playlist_id',
existing_type=mysql.INTEGER(display_width=11),
nullable=True)
op.drop_column('playlisttracks', 'row')
op.drop_table('notes')
# ### end Alembic commands ###

View File

@ -0,0 +1,28 @@
"""Add column for bitrate in Tracks
Revision ID: ed3100326c38
Revises: fe2e127b3332
Create Date: 2022-08-22 16:16:42.181848
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'ed3100326c38'
down_revision = 'fe2e127b3332'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('tracks', sa.Column('bitrate', sa.Integer(), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('tracks', 'bitrate')
# ### end Alembic commands ###

View File

@ -0,0 +1,28 @@
"""Add sort to playlist association table
Revision ID: f071129cbd93
Revises: f07b96a5e60f
Create Date: 2021-03-28 11:19:31.944110
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'f071129cbd93'
down_revision = 'f07b96a5e60f'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('playlistracks', sa.Column('sort', sa.Integer(), nullable=False))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('playlistracks', 'sort')
# ### end Alembic commands ###

View File

@ -0,0 +1,63 @@
"""Add playlist and playtimes
Revision ID: f07b96a5e60f
Revises: b0983648595e
Create Date: 2021-03-27 19:53:09.524989
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "f07b96a5e60f"
down_revision = "b0983648595e"
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"playdates",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("lastplayed", sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_playdates_lastplayed"), "playdates", ["lastplayed"], unique=False
)
op.create_table(
"playlists",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("name", sa.String(length=32), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("name"),
)
op.create_table(
"playlistracks",
sa.Column("playlist_id", sa.Integer(), nullable=True),
sa.Column("track_id", sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(
["playlist_id"],
["playlists.id"],
),
sa.ForeignKeyConstraint(
["track_id"],
["tracks.id"],
),
)
op.add_column("tracks", sa.Column("playdates_id", sa.Integer(), nullable=True))
op.create_foreign_key(None, "tracks", "playdates", ["playdates_id"], ["id"])
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, "tracks", type_="foreignkey")
op.drop_column("tracks", "playdates_id")
op.drop_table("playlistracks")
op.drop_table("playlists")
op.drop_index(op.f("ix_playdates_lastplayed"), table_name="playdates")
op.drop_table("playdates")
# ### end Alembic commands ###

View File

@ -0,0 +1,28 @@
"""Don't allow duplicate track paths
Revision ID: fe2e127b3332
Revises: 0c604bf490f8
Create Date: 2022-08-21 19:46:35.768659
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'fe2e127b3332'
down_revision = '0c604bf490f8'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_unique_constraint(None, 'tracks', ['path'])
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, 'tracks', type_='unique')
# ### end Alembic commands ###

6
mmimport Executable file
View File

@ -0,0 +1,6 @@
#!/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

2039
poetry.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,73 +1,57 @@
[project] [tool.poetry]
name = "musicmuster" name = "musicmuster"
version = "4.1.10" version = "1.7.5"
description = "Music player for internet radio" description = "Music player for internet radio"
authors = [{ name = "Keith Edmunds", email = "kae@midnighthax.com" }] authors = ["Keith Edmunds <kae@midnighthax.com>"]
requires-python = ">=3.13,<4"
readme = "README.md"
dependencies = [
"alchemical>=1.0.2",
"alembic>=1.14.0",
"colorlog>=6.9.0",
"fuzzywuzzy>=0.18.0",
"mutagen>=1.47.0",
"mysqlclient>=2.2.5",
"obs-websocket-py>=1.0",
"psutil>=6.1.0",
"pydub>=0.25.1",
"pydymenu>=0.5.2",
"pyfzf>=0.3.1",
"pygame>=2.6.1",
"pyqt6>=6.7.1",
"pyqt6-webengine>=6.7.0",
"pyqtgraph>=0.13.3",
"python-levenshtein>=0.26.1",
"python-slugify>=8.0.4",
"python-vlc>=3.0.21203",
"SQLAlchemy>=2.0.36",
"stackprinter>=0.2.10",
"tinytag>=1.10.1",
"types-psutil>=6.0.0.20240621",
"pyyaml (>=6.0.2,<7.0.0)",
"audioop-lts>=0.2.1",
"types-pyyaml>=6.0.12.20241230",
"dogpile-cache>=1.3.4",
"pdbpp>=0.10.3",
"filetype>=1.2.0",
"black>=25.1.0",
"slugify>=0.0.1",
]
[dependency-groups] [tool.poetry.dependencies]
dev = [ python = "^3.11"
"flakehell>=0.9.0,<0.10", tinytag = "^1.10.1"
"ipdb>=0.13.9,<0.14", SQLAlchemy = "^2.0.29"
"line-profiler>=4.2.0,<5", python-vlc = "^3.0.20123"
"mypy>=1.15.0,<2", mysqlclient = "^2.2.4"
"pudb", mutagen = "^1.47.0"
"pydub-stubs>=0.25.1,<0.26", alembic = "^1.13.1"
"pytest>=8.3.4,<9", psutil = "^5.9.8"
"pytest-qt>=4.4.0,<5", pydub = "^0.25.1"
"black>=25.1.0,<26", types-psutil = "^5.9.5.20240423"
"pytest-cov>=6.0.0,<7", python-slugify = "^8.0.4"
] pyfzf = "^0.3.1"
pydymenu = "^0.5.2"
stackprinter = "^0.2.10"
pyqt6 = "^6.7.0"
pyqt6-webengine = "^6.7.0"
pyqtgraph = "^0.13.3"
colorlog = "^6.8.2"
alchemical = "^1.0.2"
obs-websocket-py = "^1.0"
pygame = "^2.6.0"
[tool.uv] [tool.poetry.dev-dependencies]
package = false ipdb = "^0.13.9"
pytest-qt = "^4.4.0"
pydub-stubs = "^0.25.1"
line-profiler = "^4.1.3"
flakehell = "^0.9.0"
[tool.poetry.group.dev.dependencies]
pudb = "*"
sphinx = "^7.0.1"
flakehell = "^0.9.0"
mypy = "^1.7.0"
pdbp = "^1.5.0"
pytest-cov = "^5.0.0"
pytest = "^8.1.1"
snoop = "^0.4.3"
black = "^24.3.0"
[build-system] [build-system]
requires = ["hatchling"] requires = ["poetry-core>=1.0.0"]
build-backend = "hatchling.build" build-backend = "poetry.core.masonry.api"
[tool.mypy] [tool.mypy]
mypy_path = "/home/kae/git/musicmuster/app" mypy_path = "/home/kae/git/musicmuster/app"
explicit_package_bases = true explicit_package_bases = true
python_version = 3.11
warn_unused_configs = true
disallow_incomplete_defs = true
[tool.pylsp.plugins.pycodestyle]
maxLineLength = 88
[tool.pytest.ini_options] [tool.pytest.ini_options]
addopts = "--exitfirst --showlocals --capture=no" addopts = "--exitfirst --showlocals --capture=no"
@ -78,4 +62,3 @@ filterwarnings = ["ignore:'audioop' is deprecated", "ignore:pkg_resources"]
exclude = ["migrations", "app/ui", "archive"] exclude = ["migrations", "app/ui", "archive"]
paths = ["app"] paths = ["app"]
make_whitelist = true make_whitelist = true

View File

@ -1,489 +0,0 @@
"""
Tests are named 'test_nnn_xxxx' where 'nn n' is a number. This is used to ensure that
the tests run in order as we rely (in some cases) upon the results of an earlier test.
Yes, we shouldn't do that.
"""
# Standard library imports
import os
import shutil
import tempfile
import unittest
from unittest.mock import MagicMock, patch
# PyQt imports
from PyQt6.QtWidgets import QDialog, QFileDialog
# Third party imports
from mutagen.mp3 import MP3 # type: ignore
import pytest
from pytestqt.plugin import QtBot # type: ignore
# App imports
from app import musicmuster
from app.models import (
db,
Playlists,
Tracks,
)
from config import Config
from file_importer import FileImporter
# Custom fixture to adapt qtbot for use with unittest.TestCase
@pytest.fixture(scope="class")
def qtbot_adapter(qapp, request):
"""Adapt qtbot fixture for usefixtures and unittest.TestCase"""
request.cls.qtbot = QtBot(request)
# Fixture for tmp_path to be available in the class
@pytest.fixture(scope="class")
def class_tmp_path(request, tmp_path_factory):
"""Provide a class-wide tmp_path"""
request.cls.tmp_path = tmp_path_factory.mktemp("pytest_tmp")
@pytest.mark.usefixtures("qtbot_adapter", "class_tmp_path")
class MyTestCase(unittest.TestCase):
@classmethod
def setUpClass(cls):
"""Runs once before any test in this class"""
db.create_all()
cls.widget = musicmuster.Window()
# Create a playlist for all tests
playlist_name = "file importer playlist"
with db.Session() as session:
playlist = Playlists(session=session, name=playlist_name, template_id=0)
cls.widget._open_playlist(playlist)
# Create our musicstore
cls.import_source = tempfile.mkdtemp(suffix="_MMsource_pytest", dir="/tmp")
Config.REPLACE_FILES_DEFAULT_SOURCE = cls.import_source
cls.musicstore = tempfile.mkdtemp(suffix="_MMstore_pytest", dir="/tmp")
Config.IMPORT_DESTINATION = cls.musicstore
@classmethod
def tearDownClass(cls):
"""Runs once after all tests"""
db.drop_all()
shutil.rmtree(cls.musicstore)
shutil.rmtree(cls.import_source)
def setUp(self):
"""Runs before each test"""
with self.qtbot.waitExposed(self.widget):
self.widget.show()
def tearDown(self):
"""Runs after each test"""
self.widget.close() # Close UI to prevent side effects
def wait_for_workers(self, timeout: int = 10000):
"""
Let import threads workers run to completion
"""
def workers_empty():
assert FileImporter.workers == {}
self.qtbot.waitUntil(workers_empty, timeout=timeout)
def test_001_import_no_files(self):
"""Try importing with no files to import"""
with patch("file_importer.show_OK") as mock_show_ok:
self.widget.import_files_wrapper()
mock_show_ok.assert_called_once_with(
"File import",
f"No files in {Config.REPLACE_FILES_DEFAULT_SOURCE} to import",
None,
)
def test_002_import_file_and_cancel(self):
"""Cancel file import"""
test_track_path = "testdata/isa.mp3"
shutil.copy(test_track_path, self.import_source)
with (
patch("file_importer.PickMatch") as MockPickMatch,
patch("file_importer.show_OK") as mock_show_ok,
):
# Create a mock instance of PickMatch
mock_dialog_instance = MagicMock()
MockPickMatch.return_value = mock_dialog_instance
# Simulate the user clicking OK in the dialog
mock_dialog_instance.exec.return_value = QDialog.DialogCode.Rejected
mock_dialog_instance.selected_track_id = -1 # Simulated return value
self.widget.import_files_wrapper()
# Ensure PickMatch was instantiated correctly
MockPickMatch.assert_called_once_with(
new_track_description="I'm So Afraid (Fleetwood Mac)",
choices=[("Do not import", -1, ""), ("Import as new track", 0, "")],
default=1,
)
# Verify exec() was called
mock_dialog_instance.exec.assert_called_once()
# Ensure selected_track_id was accessed after dialog.exec()
assert mock_dialog_instance.selected_track_id < 0
mock_show_ok.assert_called_once_with(
"File not imported",
"isa.mp3 will not be imported because you asked not to import this file",
)
def test_003_import_first_file(self):
"""Import file into empty directory"""
test_track_path = "testdata/isa.mp3"
shutil.copy(test_track_path, self.import_source)
with patch("file_importer.PickMatch") as MockPickMatch:
# Create a mock instance of PickMatch
mock_dialog_instance = MagicMock()
MockPickMatch.return_value = mock_dialog_instance
# Simulate the user clicking OK in the dialog
mock_dialog_instance.exec.return_value = QDialog.DialogCode.Accepted
mock_dialog_instance.selected_track_id = 0 # Simulated return value
self.widget.import_files_wrapper()
# Ensure PickMatch was instantiated correctly
MockPickMatch.assert_called_once_with(
new_track_description="I'm So Afraid (Fleetwood Mac)",
choices=[("Do not import", -1, ""), ("Import as new track", 0, "")],
default=1,
)
# Verify exec() was called
mock_dialog_instance.exec.assert_called_once()
# Ensure selected_track_id was accessed after dialog.exec()
assert mock_dialog_instance.selected_track_id == 0
self.wait_for_workers()
# Check track was imported
with db.Session() as session:
tracks = Tracks.get_all(session)
assert len(tracks) == 1
track = tracks[0]
assert track.title == "I'm So Afraid"
assert track.artist == "Fleetwood Mac"
track_file = os.path.join(
self.musicstore, os.path.basename(test_track_path)
)
assert track.path == track_file
assert os.path.exists(track_file)
assert os.listdir(self.import_source) == []
def test_004_import_second_file(self):
"""Import a second file"""
test_track_path = "testdata/lovecats.mp3"
shutil.copy(test_track_path, self.import_source)
with patch("file_importer.PickMatch") as MockPickMatch:
# Create a mock instance of PickMatch
mock_dialog_instance = MagicMock()
MockPickMatch.return_value = mock_dialog_instance
# Simulate the user clicking OK in the dialog
mock_dialog_instance.exec.return_value = QDialog.DialogCode.Accepted
mock_dialog_instance.selected_track_id = 0 # Simulated return value
self.widget.import_files_wrapper()
# Ensure PickMatch was instantiated correctly
MockPickMatch.assert_called_once_with(
new_track_description="The Lovecats (The Cure)",
choices=[("Do not import", -1, ""), ("Import as new track", 0, "")],
default=1,
)
# Verify exec() was called
mock_dialog_instance.exec.assert_called_once()
# Ensure selected_track_id was accessed after dialog.exec()
assert mock_dialog_instance.selected_track_id == 0
self.wait_for_workers()
# Check track was imported
with db.Session() as session:
tracks = Tracks.get_all(session)
assert len(tracks) == 2
track = tracks[1]
assert track.title == "The Lovecats"
assert track.artist == "The Cure"
track_file = os.path.join(
self.musicstore, os.path.basename(test_track_path)
)
assert track.path == track_file
assert os.path.exists(track_file)
assert os.listdir(self.import_source) == []
def test_005_replace_file(self):
"""Import the same file again and update existing track"""
test_track_path = "testdata/lovecats.mp3"
shutil.copy(test_track_path, self.import_source)
with patch("file_importer.PickMatch") as MockPickMatch:
# Create a mock instance of PickMatch
mock_dialog_instance = MagicMock()
MockPickMatch.return_value = mock_dialog_instance
# Simulate the user clicking OK in the dialog
mock_dialog_instance.exec.return_value = QDialog.DialogCode.Accepted
mock_dialog_instance.selected_track_id = 2 # Simulated return value
self.widget.import_files_wrapper()
# Ensure PickMatch was instantiated correctly
MockPickMatch.assert_called_once_with(
new_track_description="The Lovecats (The Cure)",
choices=[
("Do not import", -1, ""),
("Import as new track", 0, ""),
(
"The Lovecats (The Cure) (100%)",
2,
os.path.join(
self.musicstore, os.path.basename(test_track_path)
),
),
],
default=2,
)
# Verify exec() was called
mock_dialog_instance.exec.assert_called_once()
self.wait_for_workers()
# Check track was imported
with db.Session() as session:
tracks = Tracks.get_all(session)
assert len(tracks) == 2
track = tracks[1]
assert track.title == "The Lovecats"
assert track.artist == "The Cure"
assert track.id == 2
track_file = os.path.join(
self.musicstore, os.path.basename(test_track_path)
)
assert track.path == track_file
assert os.path.exists(track_file)
assert os.listdir(self.import_source) == []
def test_006_import_file_no_tags(self) -> None:
"""Try to import untagged file"""
test_track_path = "testdata/lovecats.mp3"
test_filename = os.path.basename(test_track_path)
shutil.copy(test_track_path, self.import_source)
import_file = os.path.join(self.import_source, test_filename)
assert os.path.exists(import_file)
# Remove tags
src = MP3(import_file)
src.delete()
src.save()
with patch("file_importer.show_OK") as mock_show_ok:
self.widget.import_files_wrapper()
mock_show_ok.assert_called_once_with(
"File not imported",
f"{test_filename} will not be imported because of tag errors "
f"(Missing tags in {import_file})",
)
def test_007_import_unreadable_file(self) -> None:
"""Import unreadable file"""
test_track_path = "testdata/lovecats.mp3"
test_filename = os.path.basename(test_track_path)
shutil.copy(test_track_path, self.import_source)
import_file = os.path.join(self.import_source, test_filename)
assert os.path.exists(import_file)
# Make undreadable
os.chmod(import_file, 0)
with patch("file_importer.show_OK") as mock_show_ok:
self.widget.import_files_wrapper()
mock_show_ok.assert_called_once_with(
"File not imported",
f"{test_filename} will not be imported because {import_file} is unreadable",
)
# clean up
os.chmod(import_file, 0o777)
os.unlink(import_file)
def test_008_import_new_file_existing_destination(self) -> None:
"""Import duplicate file"""
test_track_path = "testdata/lovecats.mp3"
test_filename = os.path.basename(test_track_path)
new_destination = os.path.join(self.musicstore, "lc2.mp3")
shutil.copy(test_track_path, self.import_source)
import_file = os.path.join(self.import_source, test_filename)
assert os.path.exists(import_file)
with (
patch("file_importer.PickMatch") as MockPickMatch,
patch.object(
QFileDialog, "getSaveFileName", return_value=(new_destination, "")
) as mock_file_dialog,
patch("file_importer.show_OK") as mock_show_ok,
):
mock_file_dialog.return_value = (
new_destination,
"",
) # Ensure mock correctly returns expected value
# Create a mock instance of PickMatch
mock_dialog_instance = MagicMock()
MockPickMatch.return_value = mock_dialog_instance
# Simulate the user clicking OK in the dialog
mock_dialog_instance.exec.return_value = QDialog.DialogCode.Accepted
mock_dialog_instance.selected_track_id = 0 # Simulated return value
self.widget.import_files_wrapper()
# Ensure PickMatch was instantiated correctly
MockPickMatch.assert_called_once_with(
new_track_description="The Lovecats (The Cure)",
choices=[
("Do not import", -1, ""),
("Import as new track", 0, ""),
(
"The Lovecats (The Cure) (100%)",
2,
os.path.join(
self.musicstore, os.path.basename(test_track_path)
),
),
],
default=2,
)
# Verify exec() was called
mock_dialog_instance.exec.assert_called_once()
destination = os.path.join(self.musicstore, test_filename)
mock_show_ok.assert_called_once_with(
title="Desintation path exists",
msg=f"New import requested but default destination path ({destination}) "
"already exists. Click OK and choose where to save this track",
parent=None,
)
self.wait_for_workers()
# Ensure QFileDialog was called and returned expected value
assert mock_file_dialog.called # Ensure the mock was used
result = mock_file_dialog()
assert result[0] == new_destination # Validate return value
# Check track was imported
with db.Session() as session:
tracks = Tracks.get_all(session)
assert len(tracks) == 3
track = tracks[2]
assert track.title == "The Lovecats"
assert track.artist == "The Cure"
assert track.id == 3
assert track.path == new_destination
assert os.path.exists(new_destination)
assert os.listdir(self.import_source) == []
# Remove file so as not to interfere with later tests
session.delete(track)
tracks = Tracks.get_all(session)
assert len(tracks) == 2
session.commit()
os.unlink(new_destination)
assert not os.path.exists(new_destination)
def test_009_import_similar_file(self) -> None:
"""Import file with similar, but different, title"""
test_track_path = "testdata/lovecats.mp3"
test_filename = os.path.basename(test_track_path)
shutil.copy(test_track_path, self.import_source)
import_file = os.path.join(self.import_source, test_filename)
assert os.path.exists(import_file)
# Change title tag
src = MP3(import_file)
src["TIT2"].text[0] += " xyz"
src.save()
with patch("file_importer.PickMatch") as MockPickMatch:
# Create a mock instance of PickMatch
mock_dialog_instance = MagicMock()
MockPickMatch.return_value = mock_dialog_instance
# Simulate the user clicking OK in the dialog
mock_dialog_instance.exec.return_value = QDialog.DialogCode.Accepted
mock_dialog_instance.selected_track_id = 2 # Simulated return value
self.widget.import_files_wrapper()
# Ensure PickMatch was instantiated correctly
MockPickMatch.assert_called_once_with(
new_track_description="The Lovecats xyz (The Cure)",
choices=[
("Do not import", -1, ""),
("Import as new track", 0, ""),
(
"The Lovecats (The Cure) (93%)",
2,
os.path.join(
self.musicstore, os.path.basename(test_track_path)
),
),
],
default=2,
)
# Verify exec() was called
mock_dialog_instance.exec.assert_called_once()
self.wait_for_workers()
# Check track was imported
with db.Session() as session:
tracks = Tracks.get_all(session)
assert len(tracks) == 2
track = tracks[1]
assert track.title == "The Lovecats xyz"
assert track.artist == "The Cure"
assert track.id == 2
track_file = os.path.join(
self.musicstore, os.path.basename(test_track_path)
)
assert track.path == track_file
assert os.path.exists(track_file)
assert os.listdir(self.import_source) == []

View File

@ -55,8 +55,8 @@ class TestMMHelpers(unittest.TestCase):
with open(test_track_data) as f: with open(test_track_data) as f:
testdata = eval(f.read()) testdata = eval(f.read())
assert tags.artist == testdata["artist"] assert tags["artist"] == testdata["artist"]
assert tags.title == testdata["title"] assert tags["title"] == testdata["title"]
def test_get_relative_date(self): def test_get_relative_date(self):
assert get_relative_date(None) == "Never" assert get_relative_date(None) == "Never"
@ -64,9 +64,9 @@ class TestMMHelpers(unittest.TestCase):
today_at_11 = dt.datetime.now().replace(hour=11, minute=0) today_at_11 = dt.datetime.now().replace(hour=11, minute=0)
assert get_relative_date(today_at_10, today_at_11) == "Today 10:00" assert get_relative_date(today_at_10, today_at_11) == "Today 10:00"
eight_days_ago = today_at_10 - dt.timedelta(days=8) eight_days_ago = today_at_10 - dt.timedelta(days=8)
assert get_relative_date(eight_days_ago, today_at_11) == "1 week, 1 day" assert get_relative_date(eight_days_ago, today_at_11) == "1 week, 1 day ago"
sixteen_days_ago = today_at_10 - dt.timedelta(days=16) sixteen_days_ago = today_at_10 - dt.timedelta(days=16)
assert get_relative_date(sixteen_days_ago, today_at_11) == "2 weeks, 2 days" assert get_relative_date(sixteen_days_ago, today_at_11) == "2 weeks, 2 days ago"
def test_leading_silence(self): def test_leading_silence(self):
test_track_path = "testdata/isa.mp3" test_track_path = "testdata/isa.mp3"

View File

@ -21,9 +21,7 @@ from app.models import (
class TestMMModels(unittest.TestCase): class TestMMModels(unittest.TestCase):
def setUp(self): def setUp(self):
"""Runs before each test"""
db.create_all() db.create_all()
NoteColours.invalidate_cache()
with db.Session() as session: with db.Session() as session:
track1_path = "testdata/isa.mp3" track1_path = "testdata/isa.mp3"
@ -33,7 +31,6 @@ class TestMMModels(unittest.TestCase):
self.track2 = Tracks(session, **helpers.get_all_track_metadata(track2_path)) self.track2 = Tracks(session, **helpers.get_all_track_metadata(track2_path))
def tearDown(self): def tearDown(self):
"""Runs after each test"""
db.drop_all() db.drop_all()
def test_track_repr(self): def test_track_repr(self):
@ -73,7 +70,7 @@ class TestMMModels(unittest.TestCase):
NoteColours(session, substring="substring", colour=note_colour) NoteColours(session, substring="substring", colour=note_colour)
result = NoteColours.get_colour(session, "xyz") result = NoteColours.get_colour(session, "xyz")
assert result == "" assert result is None
def test_notecolours_get_colour_match(self): def test_notecolours_get_colour_match(self):
note_colour = "#4bcdef" note_colour = "#4bcdef"
@ -111,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", template_id=0) playlist = Playlists(session, "my playlist")
assert playlist assert playlist
# test repr # test repr
_ = str(playlist) _ = str(playlist)
@ -122,18 +119,23 @@ 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
session.delete(playlist) playlist.delete(session)
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", template_id=0) playlist = Playlists(session, "my playlist")
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
@ -153,8 +155,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, template_id=0) playlist1 = Playlists(session, p1_name)
_ = Playlists(session, p2_name, template_id=0) _ = Playlists(session, p2_name)
all_playlists = Playlists.get_all(session) all_playlists = Playlists.get_all(session)
assert len(all_playlists) == 2 assert len(all_playlists) == 2
@ -203,7 +205,7 @@ class TestMMModels(unittest.TestCase):
nc = NoteColours(session, substring="x", colour="x") nc = NoteColours(session, substring="x", colour="x")
_ = str(nc) _ = str(nc)
def test_get_colour_1(self): def test_get_colour(self):
"""Test for errors in execution""" """Test for errors in execution"""
GOOD_STRING = "cantelope" GOOD_STRING = "cantelope"
@ -216,42 +218,22 @@ class TestMMModels(unittest.TestCase):
session, substring=SUBSTR, colour=COLOUR, is_casesensitive=True session, substring=SUBSTR, colour=COLOUR, is_casesensitive=True
) )
session.commit()
_ = nc1.get_colour(session, "") _ = nc1.get_colour(session, "")
colour = nc1.get_colour(session, GOOD_STRING) colour = nc1.get_colour(session, GOOD_STRING)
assert colour == COLOUR assert colour == COLOUR
colour = nc1.get_colour(session, BAD_STRING) colour = nc1.get_colour(session, BAD_STRING)
assert colour == "" assert colour is None
def test_get_colour_2(self):
"""Test for errors in execution"""
GOOD_STRING = "cantelope"
BAD_STRING = "ericTheBee"
SUBSTR = "ant"
COLOUR = "blue"
with db.Session() as session:
nc2 = NoteColours( nc2 = NoteColours(
session, substring=".*" + SUBSTR, colour=COLOUR, is_regex=True session, substring=".*" + SUBSTR, colour=COLOUR, is_regex=True
) )
session.commit()
colour = nc2.get_colour(session, GOOD_STRING) colour = nc2.get_colour(session, GOOD_STRING)
assert colour == COLOUR assert colour == COLOUR
colour = nc2.get_colour(session, BAD_STRING) colour = nc2.get_colour(session, BAD_STRING)
assert colour == "" assert colour is None
def test_get_colour_3(self):
"""Test for errors in execution"""
GOOD_STRING = "cantelope"
BAD_STRING = "ericTheBee"
SUBSTR = "ant"
COLOUR = "blue"
with db.Session() as session:
nc3 = NoteColours( nc3 = NoteColours(
session, session,
substring=".*" + SUBSTR, substring=".*" + SUBSTR,
@ -259,13 +241,12 @@ class TestMMModels(unittest.TestCase):
is_regex=True, is_regex=True,
is_casesensitive=True, is_casesensitive=True,
) )
session.commit()
colour = nc3.get_colour(session, GOOD_STRING) colour = nc3.get_colour(session, GOOD_STRING)
assert colour == COLOUR assert colour == COLOUR
colour = nc3.get_colour(session, BAD_STRING) colour = nc3.get_colour(session, BAD_STRING)
assert colour == "" assert colour is None
def test_name_available(self): def test_name_available(self):
PLAYLIST_NAME = "a name" PLAYLIST_NAME = "a name"
@ -273,7 +254,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, template_id=0) playlist = Playlists(session, PLAYLIST_NAME)
assert playlist assert playlist
assert Playlists.name_is_available(session, PLAYLIST_NAME) is False assert Playlists.name_is_available(session, PLAYLIST_NAME) is False
@ -285,7 +266,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=session, name=PLAYLIST_NAME, template_id=0) playlist = Playlists(session, PLAYLIST_NAME)
plr = PlaylistRows(session, playlist.id, 1) plr = PlaylistRows(session, playlist.id, 1)
assert plr assert plr
@ -298,7 +279,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=session, name=PLAYLIST_NAME, template_id=0) playlist = Playlists(session, PLAYLIST_NAME)
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, template_id=0) self.playlist = Playlists(session, PLAYLIST_NAME)
self.model = playlistmodel.PlaylistModel(self.playlist.id, is_template=False) self.model = playlistmodel.PlaylistModel(self.playlist.id)
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)]
@ -56,7 +56,7 @@ class TestMMMiscTracks(unittest.TestCase):
assert max(self.model.playlist_rows.keys()) == 7 assert max(self.model.playlist_rows.keys()) == 7
for row in range(self.model.rowCount()): for row in range(self.model.rowCount()):
assert row in self.model.playlist_rows assert row in self.model.playlist_rows
assert self.model.playlist_rows[row].row_number == row assert self.model.playlist_rows[row].plr_rownum == row
def test_timing_one_track(self): def test_timing_one_track(self):
START_ROW = 0 START_ROW = 0
@ -66,10 +66,10 @@ class TestMMMiscTracks(unittest.TestCase):
self.model.insert_row(proposed_row_number=END_ROW, note="-") self.model.insert_row(proposed_row_number=END_ROW, note="-")
prd = self.model.playlist_rows[START_ROW] prd = self.model.playlist_rows[START_ROW]
qv_value = self.model._display_role( qv_value = self.model.display_role(
START_ROW, playlistmodel.HEADER_NOTES_COLUMN, prd START_ROW, playlistmodel.HEADER_NOTES_COLUMN, prd
) )
assert qv_value == "start [1 tracks, 4:23 unplayed]" assert qv_value.value() == "start [1 tracks, 4:23 unplayed]"
class TestMMMiscNoPlaylist(unittest.TestCase): class TestMMMiscNoPlaylist(unittest.TestCase):
@ -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, template_id=0) playlist = Playlists(session, self.PLAYLIST_NAME)
# Create a model # Create a model
model = playlistmodel.PlaylistModel(playlist.id, is_template=False) model = playlistmodel.PlaylistModel(playlist.id)
# test repr # test repr
_ = str(model) _ = str(model)
@ -109,7 +109,7 @@ class TestMMMiscNoPlaylist(unittest.TestCase):
_ = str(prd) _ = str(prd)
assert ( assert (
model._edit_role( model.edit_role(
model.rowCount() - 1, playlistmodel.Col.TITLE.value, prd model.rowCount() - 1, playlistmodel.Col.TITLE.value, prd
) )
== metadata["title"] == metadata["title"]
@ -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, template_id=0) self.playlist = Playlists(session, self.PLAYLIST_NAME)
self.model = playlistmodel.PlaylistModel(self.playlist.id, is_template=False) self.model = playlistmodel.PlaylistModel(self.playlist.id)
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))
@ -140,7 +140,7 @@ class TestMMMiscRowMove(unittest.TestCase):
# Check we have all rows and plr_rownums are correct # Check we have all rows and plr_rownums are correct
for row in range(self.model.rowCount()): for row in range(self.model.rowCount()):
assert row in self.model.playlist_rows assert row in self.model.playlist_rows
assert self.model.playlist_rows[row].row_number == row assert self.model.playlist_rows[row].plr_rownum == row
if row not in [3, 4, 5]: if row not in [3, 4, 5]:
assert self.model.playlist_rows[row].note == str(row) assert self.model.playlist_rows[row].note == str(row)
elif row == 3: elif row == 3:
@ -158,7 +158,7 @@ class TestMMMiscRowMove(unittest.TestCase):
# Check we have all rows and plr_rownums are correct # Check we have all rows and plr_rownums are correct
for row in range(self.model.rowCount()): for row in range(self.model.rowCount()):
assert row in self.model.playlist_rows assert row in self.model.playlist_rows
assert self.model.playlist_rows[row].row_number == row assert self.model.playlist_rows[row].plr_rownum == row
if row not in [3, 4]: if row not in [3, 4]:
assert self.model.playlist_rows[row].note == str(row) assert self.model.playlist_rows[row].note == str(row)
elif row == 3: elif row == 3:
@ -174,7 +174,7 @@ class TestMMMiscRowMove(unittest.TestCase):
# Check we have all rows and plr_rownums are correct # Check we have all rows and plr_rownums are correct
for row in range(self.model.rowCount()): for row in range(self.model.rowCount()):
assert row in self.model.playlist_rows assert row in self.model.playlist_rows
assert self.model.playlist_rows[row].row_number == row assert self.model.playlist_rows[row].plr_rownum == row
if row not in [2, 3, 4]: if row not in [2, 3, 4]:
assert self.model.playlist_rows[row].note == str(row) assert self.model.playlist_rows[row].note == str(row)
elif row == 2: elif row == 2:
@ -193,7 +193,7 @@ class TestMMMiscRowMove(unittest.TestCase):
new_order = [] new_order = []
for row in range(self.model.rowCount()): for row in range(self.model.rowCount()):
assert row in self.model.playlist_rows assert row in self.model.playlist_rows
assert self.model.playlist_rows[row].row_number == row assert self.model.playlist_rows[row].plr_rownum == row
new_order.append(int(self.model.playlist_rows[row].note)) new_order.append(int(self.model.playlist_rows[row].note))
assert new_order == [0, 2, 3, 6, 7, 1, 4, 5, 10, 8, 9] assert new_order == [0, 2, 3, 6, 7, 1, 4, 5, 10, 8, 9]
@ -206,7 +206,7 @@ class TestMMMiscRowMove(unittest.TestCase):
new_order = [] new_order = []
for row in range(self.model.rowCount()): for row in range(self.model.rowCount()):
assert row in self.model.playlist_rows assert row in self.model.playlist_rows
assert self.model.playlist_rows[row].row_number == row assert self.model.playlist_rows[row].plr_rownum == row
new_order.append(int(self.model.playlist_rows[row].note)) new_order.append(int(self.model.playlist_rows[row].note))
assert new_order == [0, 1, 2, 4, 3, 6, 5, 7, 8, 9, 10] assert new_order == [0, 1, 2, 4, 3, 6, 5, 7, 8, 9, 10]
@ -219,7 +219,7 @@ class TestMMMiscRowMove(unittest.TestCase):
new_order = [] new_order = []
for row in range(self.model.rowCount()): for row in range(self.model.rowCount()):
assert row in self.model.playlist_rows assert row in self.model.playlist_rows
assert self.model.playlist_rows[row].row_number == row assert self.model.playlist_rows[row].plr_rownum == row
new_order.append(int(self.model.playlist_rows[row].note)) new_order.append(int(self.model.playlist_rows[row].note))
assert new_order == [0, 1, 2, 4, 7, 3, 5, 6, 8, 9, 10] assert new_order == [0, 1, 2, 4, 7, 3, 5, 6, 8, 9, 10]
@ -232,7 +232,7 @@ class TestMMMiscRowMove(unittest.TestCase):
new_order = [] new_order = []
for row in range(self.model.rowCount()): for row in range(self.model.rowCount()):
assert row in self.model.playlist_rows assert row in self.model.playlist_rows
assert self.model.playlist_rows[row].row_number == row assert self.model.playlist_rows[row].plr_rownum == row
new_order.append(int(self.model.playlist_rows[row].note)) new_order.append(int(self.model.playlist_rows[row].note))
assert new_order == [0, 1, 2, 3, 4, 7, 8, 10, 5, 6, 9] assert new_order == [0, 1, 2, 3, 4, 7, 8, 10, 5, 6, 9]
@ -246,7 +246,7 @@ class TestMMMiscRowMove(unittest.TestCase):
new_order = [] new_order = []
for row in range(self.model.rowCount()): for row in range(self.model.rowCount()):
assert row in self.model.playlist_rows assert row in self.model.playlist_rows
assert self.model.playlist_rows[row].row_number == row assert self.model.playlist_rows[row].plr_rownum == row
new_order.append(int(self.model.playlist_rows[row].note)) new_order.append(int(self.model.playlist_rows[row].note))
assert new_order == [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10] assert new_order == [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
@ -262,7 +262,7 @@ class TestMMMiscRowMove(unittest.TestCase):
# Test against edit_role because display_role for headers is # Test against edit_role because display_role for headers is
# handled differently (sets up row span) # handled differently (sets up row span)
assert ( assert (
self.model._edit_role( self.model.edit_role(
self.model.rowCount() - 1, playlistmodel.Col.NOTE.value, prd self.model.rowCount() - 1, playlistmodel.Col.NOTE.value, prd
) )
== note_text == note_text
@ -280,7 +280,7 @@ class TestMMMiscRowMove(unittest.TestCase):
# Test against edit_role because display_role for headers is # Test against edit_role because display_role for headers is
# handled differently (sets up row span) # handled differently (sets up row span)
assert ( assert (
self.model._edit_role( self.model.edit_role(
self.model.rowCount() - 1, playlistmodel.Col.NOTE.value, prd self.model.rowCount() - 1, playlistmodel.Col.NOTE.value, prd
) )
== note_text == note_text
@ -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, template_id=0) playlist_dst = Playlists(session, destination_playlist)
model_dst = playlistmodel.PlaylistModel(playlist_dst.id, is_template=False) model_dst = playlistmodel.PlaylistModel(playlist_dst.id)
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))
@ -328,7 +328,7 @@ class TestMMMiscRowMove(unittest.TestCase):
assert len(model_src.playlist_rows) == self.ROWS_TO_CREATE - len(from_rows) assert len(model_src.playlist_rows) == self.ROWS_TO_CREATE - len(from_rows)
assert len(model_dst.playlist_rows) == self.ROWS_TO_CREATE + len(from_rows) assert len(model_dst.playlist_rows) == self.ROWS_TO_CREATE + len(from_rows)
assert sorted([a.row_number for a in model_src.playlist_rows.values()]) == list( assert sorted([a.plr_rownum for a in model_src.playlist_rows.values()]) == list(
range(len(model_src.playlist_rows)) range(len(model_src.playlist_rows))
) )
@ -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, template_id=0) playlist_dst = Playlists(session, destination_playlist)
model_dst = playlistmodel.PlaylistModel(playlist_dst.id, is_template=False) model_dst = playlistmodel.PlaylistModel(playlist_dst.id)
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))
@ -353,7 +353,7 @@ class TestMMMiscRowMove(unittest.TestCase):
index = model_dst.index( index = model_dst.index(
row_number, playlistmodel.Col.TITLE.value, QModelIndex() row_number, playlistmodel.Col.TITLE.value, QModelIndex()
) )
row_notes.append(model_dst.data(index, Qt.ItemDataRole.EditRole)) row_notes.append(model_dst.data(index, Qt.ItemDataRole.EditRole).value())
assert len(model_src.playlist_rows) == self.ROWS_TO_CREATE - len(from_rows) assert len(model_src.playlist_rows) == self.ROWS_TO_CREATE - len(from_rows)
assert len(model_dst.playlist_rows) == self.ROWS_TO_CREATE + len(from_rows) assert len(model_dst.playlist_rows) == self.ROWS_TO_CREATE + len(from_rows)
@ -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, template_id=0) playlist_dst = Playlists(session, destination_playlist)
model_dst = playlistmodel.PlaylistModel(playlist_dst.id, is_template=False) model_dst = playlistmodel.PlaylistModel(playlist_dst.id)
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))
@ -380,16 +380,16 @@ class TestMMMiscRowMove(unittest.TestCase):
index = model_dst.index( index = model_dst.index(
row_number, playlistmodel.Col.TITLE.value, QModelIndex() row_number, playlistmodel.Col.TITLE.value, QModelIndex()
) )
row_notes.append(model_dst.data(index, Qt.ItemDataRole.EditRole)) row_notes.append(model_dst.data(index, Qt.ItemDataRole.EditRole).value())
assert len(model_src.playlist_rows) == self.ROWS_TO_CREATE - len(from_rows) assert len(model_src.playlist_rows) == self.ROWS_TO_CREATE - len(from_rows)
assert len(model_dst.playlist_rows) == self.ROWS_TO_CREATE + len(from_rows) assert len(model_dst.playlist_rows) == self.ROWS_TO_CREATE + len(from_rows)
assert [int(a) for a in row_notes] == [ assert [int(a) for a in row_notes] == [
0, 0,
1, 1,
1,
3, 3,
4, 4,
1,
2, 2,
3, 3,
4, 4,

View File

@ -1,130 +0,0 @@
# 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

@ -3,15 +3,19 @@ import os
import unittest import unittest
# PyQt imports # PyQt imports
from PyQt6.QtCore import Qt
from PyQt6.QtGui import QColor
# Third party imports # Third party imports
import pytest import pytest
from pytestqt.plugin import QtBot # type: ignore from pytestqt.plugin import QtBot # type: ignore
# App imports # App imports
from config import Config
from app import playlistmodel, utilities from app import playlistmodel, utilities
from app.models import ( from app.models import (
db, db,
NoteColours,
Playlists, Playlists,
Tracks, Tracks,
) )
@ -59,6 +63,7 @@ class MyTestCase(unittest.TestCase):
"start_gap": 60, "start_gap": 60,
"fade_at": 236263, "fade_at": 236263,
"silence_at": 260343, "silence_at": 260343,
"mtime": 371900000,
}, },
2: { 2: {
"path": "testdata/mom.mp3", "path": "testdata/mom.mp3",
@ -69,6 +74,7 @@ class MyTestCase(unittest.TestCase):
"start_gap": 70, "start_gap": 70,
"fade_at": 115000, "fade_at": 115000,
"silence_at": 118000, "silence_at": 118000,
"mtime": 1642760000,
}, },
} }
@ -76,7 +82,7 @@ class MyTestCase(unittest.TestCase):
for track in self.tracks.values(): for track in self.tracks.values():
db_track = Tracks(session=session, **track) db_track = Tracks(session=session, **track)
session.add(db_track) session.add(db_track)
track["id"] = db_track.id track['id'] = db_track.id
session.commit() session.commit()
@ -90,8 +96,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, template_id=0) playlist = Playlists(session, playlist_name)
self.widget._open_playlist(playlist, is_template=False) self.widget.create_playlist_tab(playlist)
with self.qtbot.waitExposed(self.widget): with self.qtbot.waitExposed(self.widget):
self.widget.show() self.widget.show()
@ -103,8 +109,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, template_id=0) playlist = Playlists(session, playlist_name)
model = playlistmodel.PlaylistModel(playlist.id, is_template=False) model = playlistmodel.PlaylistModel(playlist.id)
# Add a track with a note # Add a track with a note
model.insert_row( model.insert_row(
@ -130,16 +136,15 @@ class MyTestCase(unittest.TestCase):
from config import Config from config import Config
Config.ROOT = os.path.join(os.path.dirname(__file__), "testdata") Config.ROOT = os.path.join(os.path.dirname(__file__), 'testdata')
with db.Session() as session: with db.Session() as session:
utilities.check_db(session) utilities.check_db(session)
utilities.update_bitrates(session) utilities.update_bitrates(session)
# def test_meta_all_clear(qtbot, session): # def test_meta_all_clear(qtbot, session):
# # Create playlist # # Create playlist
# playlist = models.Playlists(session, "my playlist", template_id=0) # playlist = models.Playlists(session, "my playlist")
# playlist_tab = playlists.PlaylistTab(None, session, playlist.id) # playlist_tab = playlists.PlaylistTab(None, session, playlist.id)
# # Add some tracks # # Add some tracks
@ -167,8 +172,7 @@ 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
@ -249,7 +253,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", template_id=0) # playlist = models.Playlists(session, "my playlist")
# playlist_tab = playlists.PlaylistTab(None, session, playlist.id) # playlist_tab = playlists.PlaylistTab(None, session, playlist.id)
# # Add some tracks # # Add some tracks
@ -275,7 +279,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", template_id=0) # playlist = models.Playlists(session, "test playlist")
# playlist_tab = playlists.PlaylistTab(window, session, playlist.id) # playlist_tab = playlists.PlaylistTab(window, session, playlist.id)
# # Add some tracks # # Add some tracks
@ -307,7 +311,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, template_id=0) # playlist = models.Playlists(session, playlist_name)
# 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)

1316
uv.lock

File diff suppressed because it is too large Load Diff

16
web.py Executable file
View File

@ -0,0 +1,16 @@
#!/usr/bin/env python3
import sys
from PyQt6.QtWidgets import QApplication, QLabel
from PyQt6.QtGui import QColor, QPalette
app = QApplication(sys.argv)
pal = app.palette()
pal.setColor(QPalette.ColorRole.WindowText, QColor("#000000"))
app.setPalette(pal)
label = QLabel("my label")
label.resize(300, 200)
label.show()
sys.exit(app.exec())