Compare commits
254 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8956642e05 | ||
|
|
791fad680a | ||
|
|
8c60d6a03d | ||
|
|
7391b4e61c | ||
|
|
266be281d0 | ||
|
|
ac487a5fa5 | ||
|
|
7d1bb0d3f7 | ||
|
|
e8d9cf8f00 | ||
|
|
7e7ae7dddf | ||
|
|
25cb444335 | ||
|
|
fa14fc7c52 | ||
|
|
6e51e65ba8 | ||
|
|
19b1bf3fde | ||
|
|
316b4708c6 | ||
|
|
4fd9a0381f | ||
|
|
88cce738d7 | ||
|
|
9720c11ecc | ||
|
|
ca4c490091 | ||
|
|
1749f0a0b8 | ||
|
|
c9ff1aa668 | ||
|
|
49776731bf | ||
|
|
9bf1ab29a8 | ||
|
|
4e51b44b44 | ||
|
|
582803dccc | ||
|
|
5f9fd31dfd | ||
|
|
74402f640f | ||
|
|
963da0b5d0 | ||
|
|
85493de179 | ||
|
|
2f8afeb814 | ||
|
|
3b004567df | ||
|
|
76039aa5e6 | ||
|
|
1f10692c15 | ||
|
|
6dd34b292f | ||
|
|
77a9baa34f | ||
|
|
6e2ad86fb2 | ||
|
|
be54187b48 | ||
|
|
6d56a94bca | ||
|
|
ccc1737f2d | ||
|
|
58e244af21 | ||
|
|
93839c69e2 | ||
|
|
61b00d8531 | ||
|
|
63b1d0dff4 | ||
|
|
2293c663b9 | ||
|
|
f5c77ddffd | ||
|
|
1cf75a5d42 | ||
|
|
7fd655f96f | ||
|
|
096889d6cb | ||
|
|
67c48f5022 | ||
|
|
8e48d63ebb | ||
|
|
aa6ab03555 | ||
|
|
fc02a4aa7e | ||
|
|
6223ef0ef0 | ||
|
|
76e6084419 | ||
|
|
90d72464cb | ||
|
|
82e707a6f6 | ||
|
|
b4f5d92f5d | ||
|
|
985629446a | ||
|
|
64ccb485b5 | ||
|
|
3f248d363f | ||
|
|
40756469ec | ||
|
|
306ab103b6 | ||
|
|
994d510ed9 | ||
|
|
8b8edba64d | ||
|
|
678515403c | ||
|
|
e6404d075e | ||
|
|
7c0db00b75 | ||
|
|
e4e061cf1c | ||
|
|
61021b33b8 | ||
|
|
a33589a9a1 | ||
|
|
3547046cc1 | ||
|
|
95983c73b1 | ||
|
|
499c0c6b70 | ||
|
|
33e2c4bf31 | ||
|
|
589a664971 | ||
|
|
67bf926ed8 | ||
|
|
040020e7ed | ||
|
|
911859ef49 | ||
|
|
68bdff53cf | ||
|
|
632937101a | ||
|
|
639f006a10 | ||
|
|
9e27418f80 | ||
|
|
c1448dfdd5 | ||
|
|
5f396a0993 | ||
|
|
e10c2adafe | ||
|
|
b0f6e4e819 | ||
|
|
afd3be608c | ||
|
|
aef8cb5cb5 | ||
|
|
53664857c1 | ||
|
|
c8b571b38f | ||
|
|
b3bd93d71c | ||
|
|
57ffa71c86 | ||
|
|
a8a38fa5b7 | ||
|
|
24b5cb5fe0 | ||
|
|
955bea2037 | ||
|
|
5ed7b822e1 | ||
|
|
b40c81e79a | ||
|
|
7a98fe3920 | ||
|
|
6792b2a628 | ||
|
|
c12b30a956 | ||
|
|
256de377cf | ||
|
|
a3c405912a | ||
|
|
4e73ea6e6a | ||
|
|
c9b45848dd | ||
|
|
fd0d8b15f7 | ||
|
|
7d0e1c809f | ||
|
|
5cae8e4b19 | ||
|
|
8177e03387 | ||
|
|
f4923314d8 | ||
|
|
24787578bc | ||
|
|
1f4e7cb054 | ||
|
|
92e1a1cac8 | ||
|
|
52a773176c | ||
|
|
cedc7180d4 | ||
|
|
728ac0f8dc | ||
|
|
4741c1d33f | ||
|
|
aa52f33d58 | ||
|
|
2f18ef5f44 | ||
|
|
4927f237ab | ||
|
|
d3a709642b | ||
|
|
3afcfd5856 | ||
|
|
342c0a2285 | ||
|
|
8161fb00b3 | ||
|
|
f9943dc1c4 | ||
|
|
b2000169b3 | ||
|
|
5e72f17793 | ||
|
|
4a4058d211 | ||
|
|
3b71041b66 | ||
|
|
d30bf49c88 | ||
|
|
3a3b1b712d | ||
|
|
85cfebe0f7 | ||
|
|
e23f6e2cc8 | ||
|
|
68e524594d | ||
|
|
a8931e8b2b | ||
|
|
6c05ed8c6f | ||
|
|
02c0c9c861 | ||
|
|
72930605db | ||
|
|
712c965095 | ||
|
|
4bff1a8b59 | ||
|
|
e55fab71cf | ||
|
|
7ce07c1cc7 | ||
|
|
839467a5e3 | ||
|
|
e5dc3dbf03 | ||
|
|
3fde474a5b | ||
|
|
b14b90396f | ||
|
|
937f3cd074 | ||
|
|
cb16a07451 | ||
|
|
6da6f7044b | ||
|
|
a1709e92ae | ||
|
|
b389a348c1 | ||
|
|
4c53791f4d | ||
|
|
d400ba3957 | ||
|
|
6e258a0ee2 | ||
|
|
205667faa1 | ||
|
|
d9abf72f6a | ||
|
|
96807a945c | ||
|
|
b9cb7cc326 | ||
|
|
efde8fe7bc | ||
|
|
b16845f352 | ||
|
|
42b5c2413c | ||
|
|
2ce6eb95ed | ||
|
|
734960e0f3 | ||
|
|
17d88ca8fe | ||
|
|
954b404031 | ||
|
|
0391eed88e | ||
|
|
f7f4cdc622 | ||
|
|
b7b825f0ef | ||
|
|
3c8f5910df | ||
|
|
cc01d04fb8 | ||
|
|
ac18773ebd | ||
|
|
61dcf7fc91 | ||
|
|
d0d3d5b09a | ||
|
|
ba32473f06 | ||
|
|
642e8523a2 | ||
|
|
2a93113c3f | ||
|
|
e29c7ed0ff | ||
|
|
0b30a02dde | ||
|
|
07d8ce9c41 | ||
|
|
4860c9f188 | ||
|
|
d7751008bd | ||
|
|
558554d086 | ||
|
|
417bff8663 | ||
|
|
eaac2ef4ca | ||
|
|
17ab9c1c65 | ||
|
|
2c19981cd8 | ||
|
|
27261ff871 | ||
|
|
57765a64a7 | ||
|
|
c7253e2211 | ||
|
|
ecd5c65695 | ||
|
|
8c33db170d | ||
|
|
5d5277b028 | ||
|
|
28897500c8 | ||
|
|
ac2e811ed6 | ||
|
|
0737c58dff | ||
|
|
fabf3e18bf | ||
|
|
f19fc2e8c0 | ||
|
|
40b5fc020d | ||
|
|
98a8e20baa | ||
|
|
3cec08db85 | ||
|
|
f5b26028f5 | ||
|
|
4c420d01ca | ||
|
|
7cfd2a45a2 | ||
|
|
b4fcd5f2c9 | ||
|
|
ff81447902 | ||
|
|
00b4f9ac54 | ||
|
|
61adc43b45 | ||
|
|
3783996ba4 | ||
|
|
2ce7f671ba | ||
|
|
2fb1974598 | ||
|
|
3d83de20c2 | ||
|
|
42ebf2fa7b | ||
|
|
0bcb785b30 | ||
|
|
84798fb1c5 | ||
|
|
8f94dc6c4f | ||
|
|
8ce5c037ef | ||
|
|
7ca104e53d | ||
|
|
ff76d8eb7e | ||
|
|
973096ba3f | ||
|
|
b8fcc79f8e | ||
|
|
27012a9658 | ||
|
|
1d5fe3e57e | ||
|
|
40cad1c98f | ||
|
|
5f5bb27a5f | ||
|
|
50d1e8bd4a | ||
|
|
feb8f0b6d7 | ||
|
|
1825e48e92 | ||
|
|
2b8a911a78 | ||
|
|
a95ded1551 | ||
|
|
2d582738e3 | ||
|
|
0c76227bbc | ||
|
|
bd7fb79610 | ||
|
|
59b6b87186 | ||
|
|
b15687a4c6 | ||
|
|
076451ff89 | ||
|
|
d6f55c5987 | ||
|
|
4a85d7ea84 | ||
|
|
3c01fb63c3 | ||
|
|
b423ab0624 | ||
|
|
051d8cf0ef | ||
|
|
1513ad96d8 | ||
|
|
04c2c6377a | ||
|
|
9973f00055 | ||
|
|
53e169ae6b | ||
|
|
234f6fcdbb | ||
|
|
7658dc354c | ||
|
|
3c884e54ca | ||
|
|
d7a37151b7 | ||
|
|
96080cdca0 | ||
|
|
434e45b080 | ||
|
|
829172177c | ||
|
|
30d8b0d5c8 | ||
|
|
a51dd3a998 | ||
|
|
7a6c8a0f95 | ||
|
|
faf18f431e | ||
|
|
5f3119be1f |
5
.envrc
5
.envrc
@ -1,4 +1,5 @@
|
||||
layout poetry
|
||||
layout uv
|
||||
export LINE_PROFILE=1
|
||||
export MAIL_PASSWORD="ewacyay5seu2qske"
|
||||
export MAIL_PORT=587
|
||||
export MAIL_SERVER="smtp.fastmail.com"
|
||||
@ -12,6 +13,8 @@ branch=$(git branch --show-current)
|
||||
if [ $(pwd) == /home/kae/mm ]; then
|
||||
export MM_ENV="PRODUCTION"
|
||||
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
|
||||
export MM_ENV="PRODUCTION"
|
||||
export DATABASE_URL="mysql+mysqldb://musicmuster:musicmuster@localhost/musicmuster_prod"
|
||||
|
||||
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@ -0,0 +1 @@
|
||||
*.py diff=python
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@ -2,6 +2,7 @@
|
||||
*.pyc
|
||||
*.swp
|
||||
tags
|
||||
.venv/
|
||||
venv/
|
||||
Session.vim
|
||||
*.flac
|
||||
@ -12,3 +13,5 @@ StudioPlaylist.png
|
||||
.direnv
|
||||
tmp/
|
||||
.coverage
|
||||
profile_output*
|
||||
kae.py
|
||||
|
||||
@ -1 +1 @@
|
||||
musicmuster
|
||||
3.13
|
||||
|
||||
@ -1,78 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
from PyQt6.QtCore import Qt, QEvent, QObject
|
||||
from PyQt6.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
QApplication,
|
||||
QMainWindow,
|
||||
QMessageBox,
|
||||
QPlainTextEdit,
|
||||
QStyledItemDelegate,
|
||||
QTableWidget,
|
||||
QTableWidgetItem,
|
||||
)
|
||||
|
||||
from PyQt6.QtGui import QKeyEvent
|
||||
|
||||
from typing import cast
|
||||
|
||||
|
||||
class EscapeDelegate(QStyledItemDelegate):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
def createEditor(self, parent, option, index):
|
||||
return QPlainTextEdit(parent)
|
||||
|
||||
def eventFilter(self, editor: QObject, event: QEvent):
|
||||
"""By default, QPlainTextEdit doesn't handle enter or return"""
|
||||
|
||||
print("EscapeDelegate event handler")
|
||||
if event.type() == QEvent.Type.KeyPress:
|
||||
key_event = cast(QKeyEvent, event)
|
||||
if key_event.key() == Qt.Key.Key_Return:
|
||||
if key_event.modifiers() == (Qt.KeyboardModifier.ControlModifier):
|
||||
print("save data")
|
||||
self.commitData.emit(editor)
|
||||
self.closeEditor.emit(editor)
|
||||
return True
|
||||
elif key_event.key() == Qt.Key.Key_Escape:
|
||||
discard_edits = QMessageBox.question(
|
||||
self.parent(), "Abandon edit", "Discard changes?"
|
||||
)
|
||||
if discard_edits == QMessageBox.StandardButton.Yes:
|
||||
print("abandon edit")
|
||||
self.closeEditor.emit(editor)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class MyTableWidget(QTableWidget):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setItemDelegate(EscapeDelegate(self))
|
||||
# self.setEditTriggers(QAbstractItemView.EditTrigger.DoubleClicked)
|
||||
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.table_widget = MyTableWidget(self)
|
||||
self.table_widget.setRowCount(2)
|
||||
self.table_widget.setColumnCount(2)
|
||||
for row in range(2):
|
||||
for col in range(2):
|
||||
item = QTableWidgetItem()
|
||||
item.setText(f"Row {row}, Col {col}")
|
||||
self.table_widget.setItem(row, col, item)
|
||||
self.setCentralWidget(self.table_widget)
|
||||
|
||||
self.table_widget.resizeColumnsToContents()
|
||||
self.table_widget.resizeRowsToContents()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = QApplication([])
|
||||
window = MainWindow()
|
||||
window.show()
|
||||
app.exec()
|
||||
@ -1,94 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
from PyQt6.QtCore import Qt, QEvent, QObject, QVariant, QAbstractTableModel
|
||||
from PyQt6.QtWidgets import (
|
||||
QApplication,
|
||||
QMainWindow,
|
||||
QMessageBox,
|
||||
QPlainTextEdit,
|
||||
QStyledItemDelegate,
|
||||
QTableView,
|
||||
)
|
||||
|
||||
from PyQt6.QtGui import QKeyEvent
|
||||
|
||||
from typing import cast
|
||||
|
||||
|
||||
class EscapeDelegate(QStyledItemDelegate):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
def createEditor(self, parent, option, index):
|
||||
return QPlainTextEdit(parent)
|
||||
|
||||
def eventFilter(self, editor: QObject, event: QEvent):
|
||||
"""By default, QPlainTextEdit doesn't handle enter or return"""
|
||||
|
||||
if event.type() == QEvent.Type.KeyPress:
|
||||
key_event = cast(QKeyEvent, event)
|
||||
print(key_event.key())
|
||||
if key_event.key() == Qt.Key.Key_Return:
|
||||
if key_event.modifiers() == (Qt.KeyboardModifier.ControlModifier):
|
||||
print("save data")
|
||||
self.commitData.emit(editor)
|
||||
self.closeEditor.emit(editor)
|
||||
return True
|
||||
elif key_event.key() == Qt.Key.Key_Escape:
|
||||
discard_edits = QMessageBox.question(
|
||||
self.parent(), "Abandon edit", "Discard changes?"
|
||||
)
|
||||
if discard_edits == QMessageBox.StandardButton.Yes:
|
||||
print("abandon edit")
|
||||
self.closeEditor.emit(editor)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class MyTableWidget(QTableView):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setItemDelegate(EscapeDelegate(self))
|
||||
self.setModel(MyModel())
|
||||
|
||||
|
||||
class MyModel(QAbstractTableModel):
|
||||
def columnCount(self, index):
|
||||
return 2
|
||||
|
||||
def rowCount(self, index):
|
||||
return 2
|
||||
|
||||
def data(self, index, role):
|
||||
if not index.isValid() or not (0 <= index.row() < 2):
|
||||
return QVariant()
|
||||
|
||||
row = index.row()
|
||||
column = index.column()
|
||||
if role == Qt.ItemDataRole.DisplayRole:
|
||||
return QVariant(f"Row {row}, Col {column}")
|
||||
return QVariant()
|
||||
|
||||
def flags(self, index):
|
||||
return (
|
||||
Qt.ItemFlag.ItemIsEnabled
|
||||
| Qt.ItemFlag.ItemIsSelectable
|
||||
| Qt.ItemFlag.ItemIsEditable
|
||||
)
|
||||
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.table_widget = MyTableWidget(self)
|
||||
self.setCentralWidget(self.table_widget)
|
||||
|
||||
self.table_widget.resizeColumnsToContents()
|
||||
self.table_widget.resizeRowsToContents()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = QApplication([])
|
||||
window = MainWindow()
|
||||
window.show()
|
||||
app.exec()
|
||||
155
app/audacity_controller.py
Normal file
155
app/audacity_controller.py
Normal file
@ -0,0 +1,155 @@
|
||||
# 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()
|
||||
126
app/classes.py
126
app/classes.py
@ -1,15 +1,63 @@
|
||||
# Standard library imports
|
||||
from dataclasses import dataclass, field
|
||||
from enum import auto, Enum
|
||||
from typing import Any, Optional, NamedTuple
|
||||
from __future__ import annotations
|
||||
|
||||
# PyQt imports
|
||||
from PyQt6.QtCore import pyqtSignal, QObject
|
||||
from dataclasses import dataclass
|
||||
from enum import auto, Enum
|
||||
import functools
|
||||
import threading
|
||||
from typing import NamedTuple
|
||||
|
||||
# Third party imports
|
||||
|
||||
# PyQt imports
|
||||
from PyQt6.QtCore import (
|
||||
pyqtSignal,
|
||||
QObject,
|
||||
)
|
||||
from PyQt6.QtWidgets import (
|
||||
QProxyStyle,
|
||||
QStyle,
|
||||
QStyleOption,
|
||||
)
|
||||
|
||||
# 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):
|
||||
@ -25,16 +73,31 @@ class Col(Enum):
|
||||
NOTE = auto()
|
||||
|
||||
|
||||
@helpers.singleton
|
||||
class FileErrors(NamedTuple):
|
||||
path: str
|
||||
error: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class Filter:
|
||||
version: int = 1
|
||||
path_type: str = "contains"
|
||||
path: str = ""
|
||||
last_played_number: int = 0
|
||||
last_played_comparator: str = "before"
|
||||
last_played_unit: str = "years"
|
||||
duration_type: str = "longer than"
|
||||
duration_number: int = 0
|
||||
duration_unit: str = "minutes"
|
||||
|
||||
|
||||
@singleton
|
||||
@dataclass
|
||||
class MusicMusterSignals(QObject):
|
||||
"""
|
||||
Class for all MusicMuster signals. See:
|
||||
- https://zetcode.com/gui/pyqt5/eventssignals/
|
||||
- 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
|
||||
- https://stackoverflow.com/questions/62654525/emit-a-signal-from-another-class-to-main-class
|
||||
"""
|
||||
|
||||
begin_reset_model_signal = pyqtSignal(int)
|
||||
@ -53,18 +116,37 @@ class MusicMusterSignals(QObject):
|
||||
super().__init__()
|
||||
|
||||
|
||||
@dataclass
|
||||
class TrackFileData:
|
||||
"""
|
||||
Simple class to track details changes to a track file
|
||||
"""
|
||||
class PlaylistStyle(QProxyStyle):
|
||||
def drawPrimitive(self, element, option, painter, widget=None):
|
||||
"""
|
||||
Draw a line across the entire row rather than just the column
|
||||
we're hovering over.
|
||||
"""
|
||||
if (
|
||||
element == QStyle.PrimitiveElement.PE_IndicatorItemViewItemDrop
|
||||
and not option.rect.isNull()
|
||||
):
|
||||
option_new = QStyleOption(option)
|
||||
option_new.rect.setLeft(0)
|
||||
if widget:
|
||||
option_new.rect.setRight(widget.width())
|
||||
option = option_new
|
||||
super().drawPrimitive(element, option, painter, widget)
|
||||
|
||||
new_file_path: str
|
||||
track_id: int = 0
|
||||
track_path: Optional[str] = None
|
||||
obsolete_path: Optional[str] = None
|
||||
tags: dict[str, Any] = field(default_factory=dict)
|
||||
audio_metadata: dict[str, str | int | float] = field(default_factory=dict)
|
||||
|
||||
class QueryCol(Enum):
|
||||
TITLE = 0
|
||||
ARTIST = auto()
|
||||
DURATION = auto()
|
||||
LAST_PLAYED = auto()
|
||||
BITRATE = auto()
|
||||
|
||||
|
||||
class Tags(NamedTuple):
|
||||
artist: str = ""
|
||||
title: str = ""
|
||||
bitrate: int = 0
|
||||
duration: int = 0
|
||||
|
||||
|
||||
class TrackInfo(NamedTuple):
|
||||
|
||||
@ -2,7 +2,6 @@
|
||||
import datetime as dt
|
||||
import logging
|
||||
import os
|
||||
from typing import List, Optional
|
||||
|
||||
# PyQt imports
|
||||
|
||||
@ -12,7 +11,9 @@ from typing import List, Optional
|
||||
|
||||
|
||||
class Config(object):
|
||||
AUDACITY_TIMEOUT_TENTHS = 100
|
||||
AUDACITY_TEST_COMMAND = "Message"
|
||||
AUDACITY_TEST_RESPONSE = "Some message"
|
||||
AUDACITY_TIMEOUT_SECONDS = 20
|
||||
AUDIO_SEGMENT_CHUNK_SIZE = 10
|
||||
BITRATE_LOW_THRESHOLD = 192
|
||||
BITRATE_OK_THRESHOLD = 300
|
||||
@ -30,23 +31,42 @@ class Config(object):
|
||||
COLOUR_NORMAL_TAB = "#000000"
|
||||
COLOUR_NOTES_PLAYLIST = "#b8daff"
|
||||
COLOUR_ODD_PLAYLIST = "#f2f2f2"
|
||||
COLOUR_QUERYLIST_SELECTED = "#d3ffd3"
|
||||
COLOUR_UNREADABLE = "#dc3545"
|
||||
COLOUR_WARNING_TIMER = "#ffc107"
|
||||
DBFS_SILENCE = -50
|
||||
DEBUG_FUNCTIONS: List[Optional[str]] = []
|
||||
DEBUG_MODULES: List[Optional[str]] = []
|
||||
DEFAULT_COLUMN_WIDTH = 200
|
||||
DISPLAY_SQL = False
|
||||
EPOCH = dt.datetime(1970, 1, 1)
|
||||
DO_NOT_IMPORT = "Do not import"
|
||||
ENGINE_OPTIONS = dict(pool_pre_ping=True)
|
||||
# ENGINE_OPTIONS = dict(pool_pre_ping=True, echo=True)
|
||||
EPOCH = dt.datetime(1970, 1, 1)
|
||||
ERRORS_FROM = ["noreply@midnighthax.com"]
|
||||
ERRORS_TO = ["kae@midnighthax.com"]
|
||||
EXTERNAL_BROWSER_PATH = "/usr/bin/vivaldi"
|
||||
FADE_CURVE_BACKGROUND = "lightyellow"
|
||||
FADE_CURVE_FOREGROUND = "blue"
|
||||
FADE_CURVE_MS_BEFORE_FADE = 5000
|
||||
FADEOUT_DB = -10
|
||||
FADEOUT_SECONDS = 5
|
||||
FADEOUT_STEPS_PER_SECOND = 5
|
||||
FILTER_DURATION_LONGER = "longer than"
|
||||
FILTER_DURATION_MINUTES = "minutes"
|
||||
FILTER_DURATION_SECONDS = "seconds"
|
||||
FILTER_DURATION_SHORTER = "shorter than"
|
||||
FILTER_PATH_CONTAINS = "contains"
|
||||
FILTER_PATH_EXCLUDING = "excluding"
|
||||
FILTER_PLAYED_COMPARATOR_ANYTIME = "Any time"
|
||||
FILTER_PLAYED_COMPARATOR_BEFORE = "before"
|
||||
FILTER_PLAYED_COMPARATOR_NEVER = "never"
|
||||
FILTER_PLAYED_DAYS = "days"
|
||||
FILTER_PLAYED_MONTHS = "months"
|
||||
FILTER_PLAYED_WEEKS = "weeks"
|
||||
FILTER_PLAYED_YEARS = "years"
|
||||
FUZZYMATCH_MINIMUM_LIST = 60.0
|
||||
FUZZYMATCH_MINIMUM_SELECT_ARTIST = 80.0
|
||||
FUZZYMATCH_MINIMUM_SELECT_TITLE = 80.0
|
||||
FUZZYMATCH_SHOW_SCORES = True
|
||||
HEADER_ARTIST = "Artist"
|
||||
HEADER_BITRATE = "bps"
|
||||
HEADER_DURATION = "Length"
|
||||
@ -58,6 +78,9 @@ class Config(object):
|
||||
HEADER_START_TIME = "Start"
|
||||
HEADER_TITLE = "Title"
|
||||
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
|
||||
INTRO_SECONDS_FORMAT = ".1f"
|
||||
INTRO_SECONDS_WARNING_MS = 3000
|
||||
@ -71,37 +94,52 @@ class Config(object):
|
||||
MAIL_SERVER = os.environ.get("MAIL_SERVER") or "woodlands.midnighthax.com"
|
||||
MAIL_USERNAME = os.environ.get("MAIL_USERNAME")
|
||||
MAIL_USE_TLS = os.environ.get("MAIL_USE_TLS") is not None
|
||||
MAIN_WINDOW_TITLE = "MusicMuster"
|
||||
MAX_IMPORT_MATCHES = 5
|
||||
MAX_IMPORT_THREADS = 3
|
||||
MAX_INFO_TABS = 5
|
||||
MAX_MISSING_FILES_TO_REPORT = 10
|
||||
MILLISECOND_SIGFIGS = 0
|
||||
MINIMUM_ROW_HEIGHT = 30
|
||||
NO_QUERY_NAME = "Select query"
|
||||
NO_TEMPLATE_NAME = "None"
|
||||
NOTE_TIME_FORMAT = "%H:%M"
|
||||
OBS_HOST = "localhost"
|
||||
OBS_PASSWORD = "auster"
|
||||
OBS_PORT = 4455
|
||||
PLAY_NEXT_GUARD_MS = 10000
|
||||
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_BACK_MS = 5000
|
||||
PREVIEW_END_BUFFER_MS = 1000
|
||||
REPLACE_FILES_DEFAULT_SOURCE = "/home/kae/music/Singles/tmp"
|
||||
RETURN_KEY_DEBOUNCE_MS = 500
|
||||
RESIZE_ROW_CHUNK_SIZE = 40
|
||||
RETURN_KEY_DEBOUNCE_MS = 1000
|
||||
ROOT = os.environ.get("ROOT") or "/home/kae/music"
|
||||
ROW_PADDING = 4
|
||||
ROWS_FROM_ZERO = True
|
||||
SCROLL_TOP_MARGIN = 3
|
||||
SECTION_ENDINGS = ("-", "+-", "-+")
|
||||
SECTION_HEADER = "[Section header]"
|
||||
SECTION_STARTS = ("+", "+-", "-+")
|
||||
SONGFACTS_ON_NEXT = False
|
||||
START_GAP_WARNING_THRESHOLD = 300
|
||||
TEXT_NO_TRACK_NO_NOTE = "[Section header]"
|
||||
SUBTOTAL_ON_ROW_ZERO = "[No subtotal on first row]"
|
||||
TOD_TIME_FORMAT = "%H:%M:%S"
|
||||
TRACK_TIME_FORMAT = "%H:%M:%S"
|
||||
VLC_MAIN_PLAYER_NAME = "MusicMuster Main Player"
|
||||
VLC_PREVIEW_PLAYER_NAME = "MusicMuster Preview Player"
|
||||
VLC_VOLUME_DEFAULT = 75
|
||||
VLC_VOLUME_DROP3db = 65
|
||||
VLC_VOLUME_DEFAULT = 100
|
||||
VLC_VOLUME_DROP3db = 70
|
||||
WARNING_MS_BEFORE_FADE = 5500
|
||||
WARNING_MS_BEFORE_SILENCE = 5500
|
||||
WEB_ZOOM_FACTOR = 1.2
|
||||
WIKIPEDIA_ON_NEXT = False
|
||||
|
||||
# These rely on earlier definitions
|
||||
HIDE_PLAYED_MODE = HIDE_PLAYED_MODE_TRACKS
|
||||
IMPORT_DESTINATION = os.path.join(ROOT, "Singles")
|
||||
REPLACE_FILES_DEFAULT_DESTINATION = os.path.dirname(REPLACE_FILES_DEFAULT_SOURCE)
|
||||
|
||||
@ -15,16 +15,17 @@ class DatabaseManager:
|
||||
|
||||
__instance = None
|
||||
|
||||
def __init__(self, database_url: str, **kwargs):
|
||||
def __init__(self, database_url: str, **kwargs: dict) -> None:
|
||||
if DatabaseManager.__instance is None:
|
||||
self.db = Alchemical(database_url, **kwargs)
|
||||
self.db.create_all()
|
||||
# Database managed by Alembic so no create_all() required
|
||||
# self.db.create_all()
|
||||
DatabaseManager.__instance = self
|
||||
else:
|
||||
raise Exception("Attempted to create a second DatabaseManager instance")
|
||||
|
||||
@staticmethod
|
||||
def get_instance(database_url: str, **kwargs):
|
||||
def get_instance(database_url: str, **kwargs: dict) -> Alchemical:
|
||||
if DatabaseManager.__instance is None:
|
||||
DatabaseManager(database_url, **kwargs)
|
||||
return DatabaseManager.__instance
|
||||
|
||||
100
app/dbtables.py
100
app/dbtables.py
@ -1,6 +1,8 @@
|
||||
# Standard library imports
|
||||
from typing import List, Optional
|
||||
from typing import Optional
|
||||
from dataclasses import asdict
|
||||
import datetime as dt
|
||||
import json
|
||||
|
||||
# PyQt imports
|
||||
|
||||
@ -13,13 +15,37 @@ from sqlalchemy import (
|
||||
String,
|
||||
)
|
||||
from sqlalchemy.ext.associationproxy import association_proxy
|
||||
from sqlalchemy.ext.hybrid import hybrid_property
|
||||
from sqlalchemy.engine.interfaces import Dialect
|
||||
from sqlalchemy.orm import (
|
||||
Mapped,
|
||||
mapped_column,
|
||||
relationship,
|
||||
)
|
||||
from sqlalchemy.types import TypeDecorator, TEXT
|
||||
|
||||
# App imports
|
||||
from classes import Filter
|
||||
|
||||
|
||||
class JSONEncodedDict(TypeDecorator):
|
||||
"""
|
||||
Custom JSON Type for MariaDB (since native JSON type is just LONGTEXT)
|
||||
"""
|
||||
|
||||
impl = TEXT
|
||||
|
||||
def process_bind_param(self, value: dict | None, dialect: Dialect) -> str | None:
|
||||
"""Convert Python dictionary to JSON string before saving."""
|
||||
if value is None:
|
||||
return None
|
||||
return json.dumps(value, default=lambda o: o.__dict__)
|
||||
|
||||
def process_result_value(self, value: str | None, dialect: Dialect) -> dict | None:
|
||||
"""Convert JSON string back to Python dictionary after retrieval."""
|
||||
if value is None:
|
||||
return None
|
||||
return json.loads(value)
|
||||
|
||||
|
||||
# Database classes
|
||||
@ -27,12 +53,14 @@ class NoteColoursTable(Model):
|
||||
__tablename__ = "notecolours"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
substring: Mapped[str] = mapped_column(String(256), index=False)
|
||||
substring: Mapped[str] = mapped_column(String(256), index=True, unique=True)
|
||||
colour: Mapped[str] = mapped_column(String(21), index=False)
|
||||
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_casesensitive: Mapped[bool] = mapped_column(default=False, index=False)
|
||||
order: Mapped[Optional[int]] = mapped_column(index=True)
|
||||
strip_substring: Mapped[bool] = mapped_column(default=True, index=False)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
@ -46,9 +74,10 @@ class PlaydatesTable(Model):
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
lastplayed: Mapped[dt.datetime] = mapped_column(index=True)
|
||||
track_id: Mapped[int] = mapped_column(ForeignKey("tracks.id"))
|
||||
track_id: Mapped[int] = mapped_column(ForeignKey("tracks.id", ondelete="CASCADE"))
|
||||
track: Mapped["TracksTable"] = relationship(
|
||||
"TracksTable", back_populates="playdates"
|
||||
"TracksTable",
|
||||
back_populates="playdates",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
@ -71,12 +100,14 @@ class PlaylistsTable(Model):
|
||||
tab: Mapped[Optional[int]] = mapped_column(default=None)
|
||||
open: Mapped[bool] = mapped_column(default=False)
|
||||
is_template: Mapped[bool] = mapped_column(default=False)
|
||||
deleted: Mapped[bool] = mapped_column(default=False)
|
||||
rows: Mapped[List["PlaylistRowsTable"]] = relationship(
|
||||
rows: Mapped[list["PlaylistRowsTable"]] = relationship(
|
||||
"PlaylistRowsTable",
|
||||
back_populates="playlist",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="PlaylistRowsTable.plr_rownum",
|
||||
order_by="PlaylistRowsTable.row_number",
|
||||
)
|
||||
favourite: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, index=False, default=False
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
@ -90,13 +121,18 @@ class PlaylistRowsTable(Model):
|
||||
__tablename__ = "playlist_rows"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
plr_rownum: Mapped[int]
|
||||
row_number: Mapped[int] = mapped_column(index=True)
|
||||
note: Mapped[str] = mapped_column(
|
||||
String(2048), index=False, default="", nullable=False
|
||||
)
|
||||
playlist_id: Mapped[int] = mapped_column(ForeignKey("playlists.id"))
|
||||
playlist_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("playlists.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
|
||||
playlist: Mapped[PlaylistsTable] = relationship(back_populates="rows")
|
||||
track_id: Mapped[Optional[int]] = mapped_column(ForeignKey("tracks.id"))
|
||||
track_id: Mapped[Optional[int]] = mapped_column(
|
||||
ForeignKey("tracks.id", ondelete="CASCADE")
|
||||
)
|
||||
track: Mapped["TracksTable"] = relationship(
|
||||
"TracksTable",
|
||||
back_populates="playlistrows",
|
||||
@ -107,12 +143,37 @@ class PlaylistRowsTable(Model):
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"<PlaylistRow(id={self.id}, playlist_id={self.playlist_id}, "
|
||||
f"<PlaylistRows(id={self.id}, playlist_id={self.playlist_id}, "
|
||||
f"track_id={self.track_id}, "
|
||||
f"note={self.note}, plr_rownum={self.plr_rownum}>"
|
||||
f"note={self.note}, row_number={self.row_number}>"
|
||||
)
|
||||
|
||||
|
||||
class QueriesTable(Model):
|
||||
__tablename__ = "queries"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
_filter_data: Mapped[dict | None] = mapped_column("filter_data", JSONEncodedDict, nullable=False)
|
||||
favourite: Mapped[bool] = mapped_column(Boolean, nullable=False, index=False, default=False)
|
||||
|
||||
def _get_filter(self) -> Filter:
|
||||
"""Convert stored JSON dictionary to a Filter object."""
|
||||
if isinstance(self._filter_data, dict):
|
||||
return Filter(**self._filter_data)
|
||||
return Filter() # Default object if None or invalid data
|
||||
|
||||
def _set_filter(self, value: Filter | None) -> None:
|
||||
"""Convert a Filter object to JSON before storing."""
|
||||
self._filter_data = asdict(value) if isinstance(value, Filter) else None
|
||||
|
||||
# Single definition of `filter`
|
||||
filter = property(_get_filter, _set_filter)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<QueriesTable(id={self.id}, name={self.name}, filter={self.filter})>"
|
||||
|
||||
|
||||
class SettingsTable(Model):
|
||||
"""Manage settings"""
|
||||
|
||||
@ -135,23 +196,26 @@ class TracksTable(Model):
|
||||
__tablename__ = "tracks"
|
||||
|
||||
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)
|
||||
bitrate: Mapped[Optional[int]] = mapped_column(default=None)
|
||||
bitrate: Mapped[int] = mapped_column(default=None)
|
||||
duration: Mapped[int] = mapped_column(index=True)
|
||||
fade_at: Mapped[int] = mapped_column(index=False)
|
||||
intro: Mapped[Optional[int]] = mapped_column(default=None)
|
||||
mtime: Mapped[float] = mapped_column(index=True)
|
||||
path: Mapped[str] = mapped_column(String(2048), index=False, unique=True)
|
||||
silence_at: Mapped[int] = mapped_column(index=False)
|
||||
start_gap: Mapped[int] = mapped_column(index=False)
|
||||
playlistrows: Mapped[List[PlaylistRowsTable]] = relationship(
|
||||
"PlaylistRowsTable", back_populates="track"
|
||||
title: Mapped[str] = mapped_column(String(256), index=True)
|
||||
|
||||
playlistrows: Mapped[list[PlaylistRowsTable]] = relationship(
|
||||
"PlaylistRowsTable",
|
||||
back_populates="track",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
playlists = association_proxy("playlistrows", "playlist")
|
||||
playdates: Mapped[List[PlaydatesTable]] = relationship(
|
||||
playdates: Mapped[list[PlaydatesTable]] = relationship(
|
||||
"PlaydatesTable",
|
||||
back_populates="track",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="joined",
|
||||
)
|
||||
|
||||
|
||||
230
app/dialogs.py
230
app/dialogs.py
@ -1,6 +1,5 @@
|
||||
# Standard library imports
|
||||
from typing import Optional
|
||||
import os
|
||||
|
||||
# PyQt imports
|
||||
from PyQt6.QtCore import QEvent, Qt
|
||||
@ -9,222 +8,22 @@ from PyQt6.QtWidgets import (
|
||||
QDialog,
|
||||
QListWidgetItem,
|
||||
QMainWindow,
|
||||
QTableWidgetItem,
|
||||
)
|
||||
|
||||
# Third party imports
|
||||
import pydymenu # type: ignore
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
||||
# App imports
|
||||
from classes import MusicMusterSignals, TrackFileData
|
||||
from config import Config
|
||||
from classes import MusicMusterSignals
|
||||
from helpers import (
|
||||
ask_yes_no,
|
||||
get_relative_date,
|
||||
get_tags,
|
||||
ms_to_mmss,
|
||||
show_warning,
|
||||
)
|
||||
from log import log
|
||||
from models import db, Settings, Tracks
|
||||
from models import Settings, Tracks
|
||||
from playlistmodel import PlaylistModel
|
||||
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
|
||||
from ui import dlg_TrackSelect_ui
|
||||
|
||||
|
||||
class TrackSelectDialog(QDialog):
|
||||
@ -232,21 +31,22 @@ class TrackSelectDialog(QDialog):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QMainWindow,
|
||||
session: Session,
|
||||
new_row_number: int,
|
||||
source_model: PlaylistModel,
|
||||
base_model: PlaylistModel,
|
||||
add_to_header: Optional[bool] = False,
|
||||
*args,
|
||||
**kwargs,
|
||||
*args: Qt.WindowType,
|
||||
**kwargs: Qt.WindowType,
|
||||
) -> None:
|
||||
"""
|
||||
Subclassed QDialog to manage track selection
|
||||
"""
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
super().__init__(parent, *args, **kwargs)
|
||||
self.session = session
|
||||
self.new_row_number = new_row_number
|
||||
self.source_model = source_model
|
||||
self.base_model = base_model
|
||||
self.add_to_header = add_to_header
|
||||
self.ui = dlg_TrackSelect_ui.Ui_Dialog()
|
||||
self.ui.setupUi(self)
|
||||
@ -290,7 +90,7 @@ class TrackSelectDialog(QDialog):
|
||||
track_id = track.id
|
||||
|
||||
if note and not track_id:
|
||||
self.source_model.insert_row(self.new_row_number, track_id, note)
|
||||
self.base_model.insert_row(self.new_row_number, track_id, note)
|
||||
self.ui.txtNote.clear()
|
||||
self.new_row_number += 1
|
||||
return
|
||||
@ -304,7 +104,7 @@ class TrackSelectDialog(QDialog):
|
||||
|
||||
# Check whether track is already in playlist
|
||||
move_existing = False
|
||||
existing_prd = self.source_model.is_track_in_playlist(track_id)
|
||||
existing_prd = self.base_model.is_track_in_playlist(track_id)
|
||||
if existing_prd is not None:
|
||||
if ask_yes_no(
|
||||
"Duplicate row",
|
||||
@ -315,21 +115,21 @@ class TrackSelectDialog(QDialog):
|
||||
|
||||
if self.add_to_header:
|
||||
if move_existing and existing_prd: # "and existing_prd" for mypy's benefit
|
||||
self.source_model.move_track_to_header(
|
||||
self.base_model.move_track_to_header(
|
||||
self.new_row_number, existing_prd, note
|
||||
)
|
||||
else:
|
||||
self.source_model.add_track_to_header(self.new_row_number, track_id)
|
||||
self.base_model.add_track_to_header(self.new_row_number, track_id)
|
||||
# Close dialog - we can only add one track to a header
|
||||
self.accept()
|
||||
else:
|
||||
# Adding a new track row
|
||||
if move_existing and existing_prd: # "and existing_prd" for mypy's benefit
|
||||
self.source_model.move_track_add_note(
|
||||
self.base_model.move_track_add_note(
|
||||
self.new_row_number, existing_prd, note
|
||||
)
|
||||
else:
|
||||
self.source_model.insert_row(self.new_row_number, track_id, note)
|
||||
self.base_model.insert_row(self.new_row_number, track_id, note)
|
||||
|
||||
self.new_row_number += 1
|
||||
|
||||
|
||||
777
app/file_importer.py
Normal file
777
app/file_importer.py
Normal file
@ -0,0 +1,777 @@
|
||||
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()
|
||||
143
app/helpers.py
143
app/helpers.py
@ -1,8 +1,7 @@
|
||||
# Standard library imports
|
||||
import datetime as dt
|
||||
from email.message import EmailMessage
|
||||
from typing import Any, Dict, Optional
|
||||
import functools
|
||||
from typing import Optional
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
@ -11,16 +10,18 @@ import ssl
|
||||
import tempfile
|
||||
|
||||
# PyQt imports
|
||||
from PyQt6.QtWidgets import QMainWindow, QMessageBox
|
||||
from PyQt6.QtWidgets import QInputDialog, QMainWindow, QMessageBox, QWidget
|
||||
|
||||
# Third party imports
|
||||
import filetype
|
||||
from mutagen.flac import FLAC # type: ignore
|
||||
from mutagen.mp3 import MP3 # type: ignore
|
||||
from pydub import AudioSegment, effects
|
||||
from pydub.utils import mediainfo
|
||||
from tinytag import TinyTag # type: ignore
|
||||
from tinytag import TinyTag, TinyTagException # type: ignore
|
||||
|
||||
# App imports
|
||||
from classes import AudioMetadata, ApplicationError, Tags
|
||||
from config import Config
|
||||
from log import log
|
||||
from models import Tracks
|
||||
@ -50,6 +51,14 @@ def ask_yes_no(
|
||||
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(
|
||||
audio_segment: AudioSegment,
|
||||
fade_threshold: float = 0.0,
|
||||
@ -72,7 +81,7 @@ def fade_point(
|
||||
fade_threshold = max_vol
|
||||
|
||||
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
|
||||
): # noqa W503
|
||||
trim_ms -= chunk_size
|
||||
@ -94,6 +103,9 @@ def file_is_unreadable(path: Optional[str]) -> bool:
|
||||
|
||||
|
||||
def get_audio_segment(path: str) -> Optional[AudioSegment]:
|
||||
if not path.endswith(audio_file_extension(path)):
|
||||
return None
|
||||
|
||||
try:
|
||||
if path.endswith(".mp3"):
|
||||
return AudioSegment.from_mp3(path)
|
||||
@ -121,25 +133,25 @@ def get_embedded_time(text: str) -> Optional[dt.datetime]:
|
||||
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 get_audio_metadata(filepath) | get_tags(filepath) | dict(path=filepath)
|
||||
return (
|
||||
get_audio_metadata(filepath)._asdict()
|
||||
| get_tags(filepath)._asdict()
|
||||
| dict(path=filepath)
|
||||
)
|
||||
|
||||
|
||||
def get_audio_metadata(filepath: str) -> Dict[str, str | int | float]:
|
||||
def get_audio_metadata(filepath: str) -> AudioMetadata:
|
||||
"""Return audio metadata"""
|
||||
|
||||
metadata: Dict[str, str | int | float] = {}
|
||||
|
||||
metadata["mtime"] = os.path.getmtime(filepath)
|
||||
|
||||
# Set start_gap, fade_at and silence_at
|
||||
audio = get_audio_segment(filepath)
|
||||
if not audio:
|
||||
audio_values = dict(start_gap=0, fade_at=0, silence_at=0)
|
||||
return AudioMetadata()
|
||||
else:
|
||||
audio_values = dict(
|
||||
return AudioMetadata(
|
||||
start_gap=leading_silence(audio),
|
||||
fade_at=int(
|
||||
round(fade_point(audio) / 1000, Config.MILLISECOND_SIGFIGS) * 1000
|
||||
@ -148,9 +160,23 @@ def get_audio_metadata(filepath: str) -> Dict[str, str | int | float]:
|
||||
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(
|
||||
@ -192,17 +218,30 @@ def get_relative_date(
|
||||
days_str = "day"
|
||||
else:
|
||||
days_str = "days"
|
||||
return f"{weeks} {weeks_str}, {days} {days_str} ago"
|
||||
return f"{weeks} {weeks_str}, {days} {days_str}"
|
||||
|
||||
|
||||
def get_tags(path: str) -> Dict[str, Any]:
|
||||
def get_tags(path: str) -> Tags:
|
||||
"""
|
||||
Return a dictionary of title, artist, duration-in-milliseconds and path.
|
||||
Return a dictionary of title, artist, bitrate and duration-in-milliseconds.
|
||||
"""
|
||||
|
||||
tag = TinyTag.get(path)
|
||||
try:
|
||||
tag = TinyTag.get(path)
|
||||
except FileNotFoundError:
|
||||
raise ApplicationError(f"File not found: {path}")
|
||||
except TinyTagException:
|
||||
raise ApplicationError(f"Can't read tags in {path}")
|
||||
|
||||
return dict(
|
||||
if (
|
||||
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,
|
||||
artist=tag.artist,
|
||||
bitrate=round(tag.bitrate),
|
||||
@ -278,7 +317,7 @@ def normalise_track(path: str) -> None:
|
||||
# Check type
|
||||
ftype = os.path.splitext(path)[1][1:]
|
||||
if ftype not in ["mp3", "flac"]:
|
||||
log.info(
|
||||
log.error(
|
||||
f"helpers.normalise_track({path}): " f"File type {ftype} not implemented"
|
||||
)
|
||||
|
||||
@ -326,6 +365,32 @@ def normalise_track(path: str) -> None:
|
||||
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:
|
||||
# From https://docs.python.org/3/library/email.examples.html
|
||||
|
||||
@ -358,16 +423,22 @@ def set_track_metadata(track: Tracks) -> None:
|
||||
audio_metadata = get_audio_metadata(track.path)
|
||||
tags = get_tags(track.path)
|
||||
|
||||
for audio_key in audio_metadata:
|
||||
setattr(track, audio_key, audio_metadata[audio_key])
|
||||
for tag_key in tags:
|
||||
setattr(track, tag_key, tags[tag_key])
|
||||
for audio_key in AudioMetadata._fields:
|
||||
setattr(track, audio_key, getattr(audio_metadata, audio_key))
|
||||
for tag_key in Tags._fields:
|
||||
setattr(track, tag_key, getattr(tags, tag_key))
|
||||
|
||||
|
||||
def show_OK(parent: QMainWindow, title: str, msg: str) -> None:
|
||||
def show_OK(title: str, msg: str, parent: Optional[QWidget] = None) -> None:
|
||||
"""Display a message to user"""
|
||||
|
||||
QMessageBox.information(parent, title, msg, buttons=QMessageBox.StandardButton.Ok)
|
||||
dlg = QMessageBox(parent)
|
||||
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:
|
||||
@ -376,22 +447,6 @@ def show_warning(parent: Optional[QMainWindow], title: str, msg: str) -> None:
|
||||
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(
|
||||
audio_segment: AudioSegment,
|
||||
silence_threshold: int = -50,
|
||||
|
||||
@ -1,92 +0,0 @@
|
||||
# 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)
|
||||
56
app/jittermonitor.py
Normal file
56
app/jittermonitor.py
Normal file
@ -0,0 +1,56 @@
|
||||
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,
|
||||
)
|
||||
154
app/log.py
154
app/log.py
@ -1,74 +1,138 @@
|
||||
#!/usr/bin/python3
|
||||
#!/usr/bin/env python3
|
||||
# Standard library imports
|
||||
from collections import defaultdict
|
||||
from functools import wraps
|
||||
import logging
|
||||
import logging.config
|
||||
import logging.handlers
|
||||
import os
|
||||
import sys
|
||||
from traceback import print_exception
|
||||
import traceback
|
||||
import yaml
|
||||
|
||||
# PyQt imports
|
||||
from PyQt6.QtWidgets import QApplication, QMessageBox
|
||||
|
||||
# Third party imports
|
||||
import colorlog
|
||||
import stackprinter # type: ignore
|
||||
|
||||
# App imports
|
||||
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):
|
||||
"""Add leveltag"""
|
||||
|
||||
def filter(self, record: logging.LogRecord):
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
# Extract the first character of the level name
|
||||
record.leveltag = record.levelname[0]
|
||||
|
||||
# We never actually filter messages out, just abuse filtering to add an
|
||||
# extra field to the LogRecord
|
||||
# We never actually filter messages out, just add an extra field
|
||||
# to the LogRecord
|
||||
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.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 log_uncaught_exceptions(type_, value, traceback):
|
||||
from helpers import send_mail
|
||||
def handle_exception(exc_type, exc_value, exc_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("\033[1;31;47m")
|
||||
print_exception(type_, value, traceback)
|
||||
print("\033[1;37;40m")
|
||||
print(
|
||||
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
|
||||
)
|
||||
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
|
||||
|
||||
send_mail(
|
||||
Config.ERRORS_TO,
|
||||
Config.ERRORS_FROM,
|
||||
"Exception (log_uncaught_exceptions) 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)
|
||||
|
||||
|
||||
sys.excepthook = log_uncaught_exceptions
|
||||
def truncate_large(obj, limit=5):
|
||||
"""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
|
||||
|
||||
55
app/logging.yaml
Normal file
55
app/logging.yaml
Normal file
@ -0,0 +1,55 @@
|
||||
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
|
||||
29
app/logging_tester.py
Executable file
29
app/logging_tester.py
Executable file
@ -0,0 +1,29 @@
|
||||
#!/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()
|
||||
104
app/menu.yaml
Normal file
104
app/menu.yaml
Normal file
@ -0,0 +1,104 @@
|
||||
menus:
|
||||
- title: "&File"
|
||||
actions:
|
||||
- text: "Save as Template"
|
||||
handler: "save_as_template"
|
||||
- text: "Manage Templates"
|
||||
handler: "manage_templates_wrapper"
|
||||
- separator: true
|
||||
- text: "Manage Queries"
|
||||
handler: "manage_queries_wrapper"
|
||||
- separator: true
|
||||
- text: "Exit"
|
||||
handler: "close"
|
||||
|
||||
- title: "&Playlist"
|
||||
actions:
|
||||
- text: "Open Playlist"
|
||||
handler: "open_existing_playlist"
|
||||
shortcut: "Ctrl+O"
|
||||
- text: "New Playlist"
|
||||
handler: "new_playlist_dynamic_submenu"
|
||||
submenu: true
|
||||
- text: "Close Playlist"
|
||||
handler: "close_playlist_tab"
|
||||
- text: "Rename Playlist"
|
||||
handler: "rename_playlist"
|
||||
- text: "Delete Playlist"
|
||||
handler: "delete_playlist"
|
||||
- separator: true
|
||||
- text: "Insert Track"
|
||||
handler: "insert_track"
|
||||
shortcut: "Ctrl+T"
|
||||
- text: "Select Track from Query"
|
||||
handler: "query_dynamic_submenu"
|
||||
submenu: true
|
||||
- text: "Insert Section Header"
|
||||
handler: "insert_header"
|
||||
shortcut: "Ctrl+H"
|
||||
- text: "Import Files"
|
||||
handler: "import_files_wrapper"
|
||||
shortcut: "Ctrl+Shift+I"
|
||||
- separator: true
|
||||
- text: "Mark for Moving"
|
||||
handler: "mark_rows_for_moving"
|
||||
shortcut: "Ctrl+C"
|
||||
- text: "Paste"
|
||||
handler: "paste_rows"
|
||||
shortcut: "Ctrl+V"
|
||||
- separator: true
|
||||
- text: "Export Playlist"
|
||||
handler: "export_playlist_tab"
|
||||
- text: "Download CSV of Played Tracks"
|
||||
handler: "download_played_tracks"
|
||||
- separator: true
|
||||
- text: "Select Duplicate Rows"
|
||||
handler: "select_duplicate_rows"
|
||||
- text: "Move Selected"
|
||||
handler: "move_selected"
|
||||
- text: "Move Unplayed"
|
||||
handler: "move_unplayed"
|
||||
- separator: true
|
||||
- text: "Clear Selection"
|
||||
handler: "clear_selection"
|
||||
shortcut: "Esc"
|
||||
store_reference: true # So we can enable/disable later
|
||||
|
||||
- title: "&Music"
|
||||
actions:
|
||||
- text: "Set Next"
|
||||
handler: "set_selected_track_next"
|
||||
shortcut: "Ctrl+N"
|
||||
- text: "Play Next"
|
||||
handler: "play_next"
|
||||
shortcut: "Return"
|
||||
- text: "Fade"
|
||||
handler: "fade"
|
||||
shortcut: "Ctrl+Z"
|
||||
- text: "Stop"
|
||||
handler: "stop"
|
||||
shortcut: "Ctrl+Alt+S"
|
||||
- text: "Resume"
|
||||
handler: "resume"
|
||||
shortcut: "Ctrl+R"
|
||||
- text: "Skip to Next"
|
||||
handler: "play_next"
|
||||
shortcut: "Ctrl+Alt+Return"
|
||||
- separator: true
|
||||
- text: "Search"
|
||||
handler: "search_playlist"
|
||||
shortcut: "/"
|
||||
- text: "Search Title in Wikipedia"
|
||||
handler: "lookup_row_in_wikipedia"
|
||||
shortcut: "Ctrl+W"
|
||||
- text: "Search Title in Songfacts"
|
||||
handler: "lookup_row_in_songfacts"
|
||||
shortcut: "Ctrl+S"
|
||||
|
||||
- title: "Help"
|
||||
actions:
|
||||
- text: "About"
|
||||
handler: "about"
|
||||
- text: "Debug"
|
||||
handler: "debug"
|
||||
|
||||
378
app/models.py
378
app/models.py
@ -1,5 +1,7 @@
|
||||
# Standard library imports
|
||||
from typing import List, Optional, Sequence
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional, Sequence
|
||||
import datetime as dt
|
||||
import os
|
||||
import re
|
||||
@ -8,22 +10,27 @@ import sys
|
||||
# PyQt imports
|
||||
|
||||
# Third party imports
|
||||
from dogpile.cache import make_region
|
||||
from dogpile.cache.api import NO_VALUE
|
||||
from sqlalchemy import (
|
||||
bindparam,
|
||||
delete,
|
||||
func,
|
||||
select,
|
||||
text,
|
||||
update,
|
||||
)
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.exc import IntegrityError, ProgrammingError
|
||||
from sqlalchemy.orm.exc import NoResultFound
|
||||
from sqlalchemy.orm import joinedload
|
||||
from sqlalchemy.orm import joinedload, selectinload
|
||||
from sqlalchemy.orm.session import Session
|
||||
from sqlalchemy.engine.row import RowMapping
|
||||
|
||||
# App imports
|
||||
from classes import ApplicationError, Filter
|
||||
from config import Config
|
||||
from dbmanager import DatabaseManager
|
||||
import dbtables
|
||||
from config import Config
|
||||
from log import log
|
||||
|
||||
|
||||
@ -34,11 +41,28 @@ if DATABASE_URL is None:
|
||||
if "unittest" in sys.modules and "sqlite" not in DATABASE_URL:
|
||||
raise ValueError("Unit tests running on non-Sqlite database")
|
||||
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
|
||||
class NoteColours(dbtables.NoteColoursTable):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session: Session,
|
||||
@ -65,62 +89,95 @@ class NoteColours(dbtables.NoteColoursTable):
|
||||
Return all records
|
||||
"""
|
||||
|
||||
return session.scalars(select(cls)).all()
|
||||
cache_key = "note_colours_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
|
||||
def get_colour(session: Session, text: str) -> Optional[str]:
|
||||
def get_colour(
|
||||
session: Session, text: str, foreground: bool = False
|
||||
) -> str:
|
||||
"""
|
||||
Parse text and return colour string if matched, else empty string
|
||||
Parse text and return background (foreground if foreground==True) colour
|
||||
string if matched, else None
|
||||
|
||||
"""
|
||||
|
||||
if not text:
|
||||
return None
|
||||
return ""
|
||||
|
||||
for rec in session.scalars(
|
||||
select(NoteColours)
|
||||
.filter(NoteColours.enabled.is_(True))
|
||||
.order_by(NoteColours.order)
|
||||
).all():
|
||||
match = False
|
||||
for rec in NoteColours.get_all(session):
|
||||
if rec.is_regex:
|
||||
flags = re.UNICODE
|
||||
if not rec.is_casesensitive:
|
||||
flags |= re.IGNORECASE
|
||||
p = re.compile(rec.substring, flags)
|
||||
if p.match(text):
|
||||
return rec.colour
|
||||
match = True
|
||||
else:
|
||||
if rec.is_casesensitive:
|
||||
if rec.substring in text:
|
||||
return rec.colour
|
||||
match = True
|
||||
else:
|
||||
if rec.substring.lower() in text.lower():
|
||||
return rec.colour
|
||||
match = True
|
||||
|
||||
return None
|
||||
if match:
|
||||
if foreground:
|
||||
return rec.foreground or ""
|
||||
else:
|
||||
return rec.colour
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def invalidate_cache() -> None:
|
||||
"""Invalidate dogpile cache"""
|
||||
|
||||
cache_region.delete("note_colours_all")
|
||||
|
||||
|
||||
class Playdates(dbtables.PlaydatesTable):
|
||||
def __init__(self, session: Session, track_id: int) -> None:
|
||||
def __init__(
|
||||
self, session: Session, track_id: int, when: Optional[dt.datetime] = None
|
||||
) -> None:
|
||||
"""Record that track was played"""
|
||||
|
||||
self.lastplayed = dt.datetime.now()
|
||||
if not when:
|
||||
self.lastplayed = dt.datetime.now()
|
||||
else:
|
||||
self.lastplayed = when
|
||||
self.track_id = track_id
|
||||
session.add(self)
|
||||
session.commit()
|
||||
|
||||
@staticmethod
|
||||
def last_playdates(
|
||||
session: Session, track_id: int, limit=5
|
||||
session: Session, track_id: int, limit: int = 5
|
||||
) -> Sequence["Playdates"]:
|
||||
"""
|
||||
Return a list of the last limit playdates for this track, sorted
|
||||
earliest to latest.
|
||||
latest to earliest.
|
||||
"""
|
||||
|
||||
return session.scalars(
|
||||
Playdates.select()
|
||||
.where(Playdates.track_id == track_id)
|
||||
.order_by(Playdates.lastplayed.asc())
|
||||
.order_by(Playdates.lastplayed.desc())
|
||||
.limit(limit)
|
||||
).all()
|
||||
|
||||
@ -143,7 +200,7 @@ class Playdates(dbtables.PlaydatesTable):
|
||||
return Config.EPOCH # pragma: no cover
|
||||
|
||||
@staticmethod
|
||||
def last_played_tracks(session: Session, limit=5) -> Sequence["Playdates"]:
|
||||
def last_played_tracks(session: Session, limit: int = 5) -> Sequence["Playdates"]:
|
||||
"""
|
||||
Return a list of the last limit tracks played, sorted
|
||||
earliest to latest.
|
||||
@ -165,13 +222,20 @@ class Playdates(dbtables.PlaydatesTable):
|
||||
|
||||
|
||||
class Playlists(dbtables.PlaylistsTable):
|
||||
def __init__(self, session: Session, name: str):
|
||||
def __init__(self, session: Session, name: str, template_id: int) -> None:
|
||||
"""Create playlist with passed name"""
|
||||
|
||||
self.name = name
|
||||
self.last_used = dt.datetime.now()
|
||||
session.add(self)
|
||||
session.commit()
|
||||
|
||||
# If a template is specified, copy from it
|
||||
if template_id:
|
||||
PlaylistRows.copy_playlist(session, template_id, self.id)
|
||||
|
||||
@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
|
||||
"""
|
||||
@ -186,30 +250,6 @@ class Playlists(dbtables.PlaylistsTable):
|
||||
self.open = False
|
||||
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
|
||||
def get_all(cls, session: Session) -> Sequence["Playlists"]:
|
||||
"""Returns a list of all playlists ordered by last use"""
|
||||
@ -225,7 +265,17 @@ class Playlists(dbtables.PlaylistsTable):
|
||||
"""Returns a list of all templates ordered by name"""
|
||||
|
||||
return session.scalars(
|
||||
select(cls).filter(cls.is_template.is_(True)).order_by(cls.name)
|
||||
select(cls).where(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()
|
||||
|
||||
@classmethod
|
||||
@ -237,7 +287,6 @@ class Playlists(dbtables.PlaylistsTable):
|
||||
.filter(
|
||||
cls.open.is_(False),
|
||||
cls.is_template.is_(False),
|
||||
cls.deleted.is_(False),
|
||||
)
|
||||
.order_by(cls.last_used.desc())
|
||||
).all()
|
||||
@ -283,7 +332,7 @@ class Playlists(dbtables.PlaylistsTable):
|
||||
) -> None:
|
||||
"""Save passed playlist as new template"""
|
||||
|
||||
template = Playlists(session, template_name)
|
||||
template = Playlists(session, template_name, template_id=0)
|
||||
if not template or not template.id:
|
||||
return
|
||||
|
||||
@ -306,7 +355,7 @@ class PlaylistRows(dbtables.PlaylistRowsTable):
|
||||
|
||||
self.playlist_id = playlist_id
|
||||
self.track_id = track_id
|
||||
self.plr_rownum = row_number
|
||||
self.row_number = row_number
|
||||
self.note = note
|
||||
session.add(self)
|
||||
session.commit()
|
||||
@ -332,7 +381,7 @@ class PlaylistRows(dbtables.PlaylistRowsTable):
|
||||
PlaylistRows(
|
||||
session=session,
|
||||
playlist_id=dst_id,
|
||||
row_number=plr.plr_rownum,
|
||||
row_number=plr.row_number,
|
||||
note=plr.note,
|
||||
track_id=plr.track_id,
|
||||
)
|
||||
@ -351,30 +400,13 @@ class PlaylistRows(dbtables.PlaylistRowsTable):
|
||||
.options(joinedload(cls.track))
|
||||
.where(
|
||||
PlaylistRows.playlist_id == playlist_id,
|
||||
PlaylistRows.plr_rownum == row_number,
|
||||
PlaylistRows.row_number == row_number,
|
||||
)
|
||||
# .options(joinedload(Tracks.playdates))
|
||||
)
|
||||
|
||||
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
|
||||
def delete_higher_rows(session: Session, playlist_id: int, maxrow: int) -> None:
|
||||
"""
|
||||
@ -385,7 +417,7 @@ class PlaylistRows(dbtables.PlaylistRowsTable):
|
||||
session.execute(
|
||||
delete(PlaylistRows).where(
|
||||
PlaylistRows.playlist_id == playlist_id,
|
||||
PlaylistRows.plr_rownum > maxrow,
|
||||
PlaylistRows.row_number > maxrow,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
@ -399,7 +431,7 @@ class PlaylistRows(dbtables.PlaylistRowsTable):
|
||||
session.execute(
|
||||
delete(PlaylistRows).where(
|
||||
PlaylistRows.playlist_id == playlist_id,
|
||||
PlaylistRows.plr_rownum == row_number,
|
||||
PlaylistRows.row_number == row_number,
|
||||
)
|
||||
)
|
||||
|
||||
@ -412,18 +444,18 @@ class PlaylistRows(dbtables.PlaylistRowsTable):
|
||||
plrs = session.scalars(
|
||||
select(PlaylistRows)
|
||||
.where(PlaylistRows.playlist_id == playlist_id)
|
||||
.order_by(PlaylistRows.plr_rownum)
|
||||
.order_by(PlaylistRows.row_number)
|
||||
).all()
|
||||
|
||||
for i, plr in enumerate(plrs):
|
||||
plr.plr_rownum = i
|
||||
plr.row_number = i
|
||||
|
||||
# Ensure new row numbers are available to the caller
|
||||
session.commit()
|
||||
|
||||
@classmethod
|
||||
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"]:
|
||||
"""
|
||||
Take a list of PlaylistRows ids and return a list of corresponding
|
||||
@ -433,7 +465,7 @@ class PlaylistRows(dbtables.PlaylistRowsTable):
|
||||
plrs = session.scalars(
|
||||
select(cls)
|
||||
.where(cls.playlist_id == playlist_id, cls.id.in_(plr_ids))
|
||||
.order_by(cls.plr_rownum)
|
||||
.order_by(cls.row_number)
|
||||
).all()
|
||||
|
||||
return plrs
|
||||
@ -443,7 +475,7 @@ class PlaylistRows(dbtables.PlaylistRowsTable):
|
||||
"""Return the last used row for playlist, or None if no rows"""
|
||||
|
||||
return session.execute(
|
||||
select(func.max(PlaylistRows.plr_rownum)).where(
|
||||
select(func.max(PlaylistRows.row_number)).where(
|
||||
PlaylistRows.playlist_id == playlist_id
|
||||
)
|
||||
).scalar_one()
|
||||
@ -475,11 +507,29 @@ class PlaylistRows(dbtables.PlaylistRowsTable):
|
||||
plrs = session.scalars(
|
||||
select(cls)
|
||||
.where(cls.playlist_id == playlist_id, cls.played.is_(True))
|
||||
.order_by(cls.plr_rownum)
|
||||
.order_by(cls.row_number)
|
||||
).all()
|
||||
|
||||
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
|
||||
def get_rows_with_tracks(
|
||||
cls,
|
||||
@ -494,7 +544,7 @@ class PlaylistRows(dbtables.PlaylistRowsTable):
|
||||
query = select(cls).where(
|
||||
cls.playlist_id == playlist_id, cls.track_id.is_not(None)
|
||||
)
|
||||
plrs = session.scalars((query).order_by(cls.plr_rownum)).all()
|
||||
plrs = session.scalars((query).order_by(cls.row_number)).all()
|
||||
|
||||
return plrs
|
||||
|
||||
@ -514,7 +564,7 @@ class PlaylistRows(dbtables.PlaylistRowsTable):
|
||||
cls.track_id.is_not(None),
|
||||
cls.played.is_(False),
|
||||
)
|
||||
.order_by(cls.plr_rownum)
|
||||
.order_by(cls.row_number)
|
||||
).all()
|
||||
|
||||
return plrs
|
||||
@ -552,17 +602,19 @@ class PlaylistRows(dbtables.PlaylistRowsTable):
|
||||
update(PlaylistRows)
|
||||
.where(
|
||||
(PlaylistRows.playlist_id == playlist_id),
|
||||
(PlaylistRows.plr_rownum >= starting_row),
|
||||
(PlaylistRows.row_number >= starting_row),
|
||||
)
|
||||
.values(plr_rownum=PlaylistRows.plr_rownum + move_by)
|
||||
.values(row_number=PlaylistRows.row_number + move_by)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def update_plr_rownumbers(
|
||||
session: Session, playlist_id: int, sqla_map: List[dict[str, int]]
|
||||
def update_plr_row_numbers(
|
||||
session: Session,
|
||||
playlist_id: int,
|
||||
sqla_map: list[dict[str, int]],
|
||||
) -> None:
|
||||
"""
|
||||
Take a {plrid: plr_rownum} dictionary and update the row numbers accordingly
|
||||
Take a {plrid: row_number} dictionary and update the row numbers accordingly
|
||||
"""
|
||||
|
||||
# Update database. Ref:
|
||||
@ -571,15 +623,46 @@ class PlaylistRows(dbtables.PlaylistRowsTable):
|
||||
update(PlaylistRows)
|
||||
.where(
|
||||
PlaylistRows.playlist_id == playlist_id,
|
||||
PlaylistRows.id == bindparam("plrid"),
|
||||
PlaylistRows.id == bindparam("playlistrow_id"),
|
||||
)
|
||||
.values(plr_rownum=bindparam("plr_rownum"))
|
||||
.values(row_number=bindparam("row_number"))
|
||||
)
|
||||
session.connection().execute(stmt, sqla_map)
|
||||
|
||||
|
||||
class Queries(dbtables.QueriesTable):
|
||||
def __init__(
|
||||
self,
|
||||
session: Session,
|
||||
name: str,
|
||||
filter: dbtables.Filter,
|
||||
favourite: bool = False,
|
||||
) -> None:
|
||||
"""Create new query"""
|
||||
|
||||
self.name = name
|
||||
self.filter = filter
|
||||
self.favourite = favourite
|
||||
session.add(self)
|
||||
session.commit()
|
||||
|
||||
@classmethod
|
||||
def get_all(cls, session: Session) -> Sequence["Queries"]:
|
||||
"""Returns a list of all queries ordered by name"""
|
||||
|
||||
return session.scalars(select(cls).order_by(cls.name)).all()
|
||||
|
||||
@classmethod
|
||||
def get_favourites(cls, session: Session) -> Sequence["Queries"]:
|
||||
"""Returns a list of favourite queries ordered by name"""
|
||||
|
||||
return session.scalars(
|
||||
select(cls).where(cls.favourite.is_(True)).order_by(cls.name)
|
||||
).all()
|
||||
|
||||
|
||||
class Settings(dbtables.SettingsTable):
|
||||
def __init__(self, session: Session, name: str):
|
||||
def __init__(self, session: Session, name: str) -> None:
|
||||
self.name = name
|
||||
session.add(self)
|
||||
session.commit()
|
||||
@ -606,9 +689,8 @@ class Tracks(dbtables.TracksTable):
|
||||
start_gap: int,
|
||||
fade_at: int,
|
||||
silence_at: int,
|
||||
mtime: int,
|
||||
bitrate: int,
|
||||
):
|
||||
) -> None:
|
||||
self.path = path
|
||||
self.title = title
|
||||
self.artist = artist
|
||||
@ -617,7 +699,6 @@ class Tracks(dbtables.TracksTable):
|
||||
self.start_gap = start_gap
|
||||
self.fade_at = fade_at
|
||||
self.silence_at = silence_at
|
||||
self.mtime = mtime
|
||||
|
||||
try:
|
||||
session.add(self)
|
||||
@ -628,25 +709,112 @@ class Tracks(dbtables.TracksTable):
|
||||
raise ValueError(error)
|
||||
|
||||
@classmethod
|
||||
def get_all(cls, session) -> List["Tracks"]:
|
||||
def get_all(cls, session: Session) -> Sequence["Tracks"]:
|
||||
"""Return a list of all tracks"""
|
||||
|
||||
return session.scalars(select(cls)).unique().all()
|
||||
|
||||
@classmethod
|
||||
def get_by_basename(
|
||||
cls, session: Session, basename: str
|
||||
) -> Optional[Sequence["Tracks"]]:
|
||||
def all_tracks_indexed_by_id(cls, session: Session) -> dict[int, Tracks]:
|
||||
"""
|
||||
Return track(s) with passed basename, or None.
|
||||
Return a dictionary of all tracks, keyed by title
|
||||
"""
|
||||
|
||||
try:
|
||||
return session.scalars(
|
||||
Tracks.select().where(Tracks.path.like("%/" + basename))
|
||||
).all()
|
||||
except NoResultFound:
|
||||
return None
|
||||
result: dict[int, Tracks] = {}
|
||||
|
||||
for track in cls.get_all(session):
|
||||
result[track.id] = track
|
||||
|
||||
return result
|
||||
|
||||
@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
|
||||
def get_by_path(cls, session: Session, path: str) -> Optional["Tracks"]:
|
||||
|
||||
@ -2,39 +2,66 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import threading
|
||||
from time import sleep
|
||||
from typing import Optional
|
||||
|
||||
# Third party imports
|
||||
# import line_profiler
|
||||
import numpy as np
|
||||
import pyqtgraph as pg # type: ignore
|
||||
from sqlalchemy.orm.session import Session
|
||||
import vlc # type: ignore
|
||||
|
||||
# PyQt imports
|
||||
from PyQt6.QtCore import (
|
||||
pyqtSignal,
|
||||
QObject,
|
||||
QRunnable,
|
||||
QThread,
|
||||
QThreadPool,
|
||||
)
|
||||
from pyqtgraph import PlotWidget
|
||||
from pyqtgraph.graphicsItems.PlotDataItem import PlotDataItem # type: ignore
|
||||
from pyqtgraph.graphicsItems.LinearRegionItem import LinearRegionItem # type: ignore
|
||||
|
||||
# App imports
|
||||
from classes import MusicMusterSignals
|
||||
from classes import ApplicationError, MusicMusterSignals
|
||||
from config import Config
|
||||
import helpers
|
||||
from log import log
|
||||
from models import db, PlaylistRows, Tracks
|
||||
from helpers import (
|
||||
file_is_unreadable,
|
||||
get_audio_segment,
|
||||
show_warning,
|
||||
)
|
||||
from models import PlaylistRows
|
||||
from vlcmanager import VLCManager
|
||||
|
||||
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):
|
||||
@ -47,13 +74,13 @@ class _AddFadeCurve(QObject):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
track_manager: _TrackManager,
|
||||
rat: RowAndTrack,
|
||||
track_path: str,
|
||||
track_fade_at: int,
|
||||
track_silence_at: int,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.track_manager = track_manager
|
||||
self.rat = rat
|
||||
self.track_path = track_path
|
||||
self.track_fade_at = track_fade_at
|
||||
self.track_silence_at = track_silence_at
|
||||
@ -67,7 +94,7 @@ class _AddFadeCurve(QObject):
|
||||
if not fc:
|
||||
log.error(f"Failed to create FadeCurve for {self.track_path=}")
|
||||
else:
|
||||
self.track_manager.fade_graph = fc
|
||||
self.rat.fade_graph = fc
|
||||
self.finished.emit()
|
||||
|
||||
|
||||
@ -81,7 +108,7 @@ class _FadeCurve:
|
||||
Set up fade graph array
|
||||
"""
|
||||
|
||||
audio = get_audio_segment(track_path)
|
||||
audio = helpers.get_audio_segment(track_path)
|
||||
if not audio:
|
||||
log.error(f"FadeCurve: could not get audio for {track_path=}")
|
||||
return None
|
||||
@ -92,8 +119,8 @@ class _FadeCurve:
|
||||
0, track_fade_at - Config.FADE_CURVE_MS_BEFORE_FADE - 1
|
||||
)
|
||||
self.end_ms: int = track_silence_at
|
||||
self.audio_segment = audio[self.start_ms : self.end_ms]
|
||||
self.graph_array = np.array(self.audio_segment.get_array_of_samples())
|
||||
audio_segment = audio[self.start_ms : self.end_ms]
|
||||
self.graph_array = np.array(audio_segment.get_array_of_samples())
|
||||
|
||||
# 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)
|
||||
@ -112,8 +139,12 @@ class _FadeCurve:
|
||||
self.curve = self.GraphWidget.plot(self.graph_array)
|
||||
if self.curve:
|
||||
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) -> None:
|
||||
def tick(self, play_time: int) -> None:
|
||||
"""Update volume fade curve"""
|
||||
|
||||
if not self.GraphWidget:
|
||||
@ -125,21 +156,21 @@ class _FadeCurve:
|
||||
|
||||
if self.region is None:
|
||||
# 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.GraphWidget.addItem(self.region)
|
||||
|
||||
# Update region position
|
||||
if self.region:
|
||||
log.debug("issue223: _FadeCurve: update region")
|
||||
self.region.setRegion([0, ms_of_graph * self.ms_to_array_factor])
|
||||
|
||||
|
||||
class _FadeTrack(QRunnable):
|
||||
def __init__(self, player: vlc.MediaPlayer, fade_seconds) -> None:
|
||||
class _FadeTrack(QThread):
|
||||
finished = pyqtSignal()
|
||||
|
||||
def __init__(self, player: vlc.MediaPlayer, fade_seconds: int) -> None:
|
||||
super().__init__()
|
||||
self.player: vlc.MediaPlayer = player
|
||||
self.fade_seconds: int = fade_seconds
|
||||
self.player = player
|
||||
self.fade_seconds = fade_seconds
|
||||
|
||||
def run(self) -> None:
|
||||
"""
|
||||
@ -151,20 +182,23 @@ class _FadeTrack(QRunnable):
|
||||
|
||||
# Reduce volume logarithmically
|
||||
total_steps = self.fade_seconds * Config.FADEOUT_STEPS_PER_SECOND
|
||||
db_reduction_per_step = Config.FADEOUT_DB / total_steps
|
||||
reduction_factor_per_step = pow(10, (db_reduction_per_step / 20))
|
||||
if total_steps > 0:
|
||||
db_reduction_per_step = Config.FADEOUT_DB / total_steps
|
||||
reduction_factor_per_step = pow(10, (db_reduction_per_step / 20))
|
||||
|
||||
volume = self.player.audio_get_volume()
|
||||
volume = self.player.audio_get_volume()
|
||||
|
||||
for i in range(1, total_steps + 1):
|
||||
self.player.audio_set_volume(
|
||||
int(volume * pow(reduction_factor_per_step, i))
|
||||
)
|
||||
sleep(1 / Config.FADEOUT_STEPS_PER_SECOND)
|
||||
for i in range(1, total_steps + 1):
|
||||
self.player.audio_set_volume(
|
||||
int(volume * pow(reduction_factor_per_step, i))
|
||||
)
|
||||
sleep(1 / Config.FADEOUT_STEPS_PER_SECOND)
|
||||
|
||||
self.player.stop()
|
||||
log.debug(f"Releasing player {self.player=}")
|
||||
self.player.release()
|
||||
self.finished.emit()
|
||||
|
||||
|
||||
# TODO can we move this into the _Music class?
|
||||
vlc_instance = VLCManager().vlc_instance
|
||||
|
||||
|
||||
class _Music:
|
||||
@ -172,14 +206,39 @@ class _Music:
|
||||
Manage the playing of music tracks
|
||||
"""
|
||||
|
||||
def __init__(self, name) -> None:
|
||||
self.VLC = vlc.Instance()
|
||||
self.VLC.set_user_agent(name, name)
|
||||
def __init__(self, name: str) -> None:
|
||||
vlc_instance.set_user_agent(name, name)
|
||||
self.player: Optional[vlc.MediaPlayer] = None
|
||||
self.name: str = name
|
||||
self.name = name
|
||||
self.max_volume: int = Config.VLC_VOLUME_DEFAULT
|
||||
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:
|
||||
"""Move player position by ms milliseconds"""
|
||||
|
||||
@ -201,25 +260,6 @@ class _Music:
|
||||
else:
|
||||
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:
|
||||
"""
|
||||
Fade the currently playing track.
|
||||
@ -234,23 +274,10 @@ class _Music:
|
||||
if not self.player.get_position() > 0 and self.player.is_playing():
|
||||
return
|
||||
|
||||
if fade_seconds <= 0:
|
||||
self.stop()
|
||||
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
|
||||
else:
|
||||
log.error("_Music: failed to allocate QThreadPool")
|
||||
self.fader_worker = _FadeTrack(self.player, fade_seconds=fade_seconds)
|
||||
self.fader_worker.finished.connect(self.player.release)
|
||||
self.fader_worker.start()
|
||||
self.start_dt = None
|
||||
|
||||
def get_playtime(self) -> int:
|
||||
"""
|
||||
@ -308,40 +335,24 @@ class _Music:
|
||||
|
||||
log.debug(f"Music[{self.name}].play({path=}, {position=}")
|
||||
|
||||
if file_is_unreadable(path):
|
||||
if helpers.file_is_unreadable(path):
|
||||
log.error(f"play({path}): path not readable")
|
||||
return None
|
||||
|
||||
media = self.VLC.media_new_path(path)
|
||||
if media is None:
|
||||
log.error(f"_Music:play: failed to create media ({path=})")
|
||||
show_warning(None, "Error loading file", f"Cannot play file ({path})")
|
||||
self.player = vlc.MediaPlayer(vlc_instance, path)
|
||||
if self.player is None:
|
||||
log.error(f"_Music:play: failed to create MediaPlayer ({path=})")
|
||||
helpers.show_warning(
|
||||
None, "Error creating MediaPlayer", f"Cannot play file ({path})"
|
||||
)
|
||||
return
|
||||
self.player = media.player_new_from_media()
|
||||
if self.player:
|
||||
_ = self.player.play()
|
||||
self.set_volume(self.max_volume)
|
||||
|
||||
if position:
|
||||
self.player.set_position(position)
|
||||
self.start_dt = start_time
|
||||
_ = self.player.play()
|
||||
self.set_volume(self.max_volume)
|
||||
|
||||
# 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)
|
||||
else:
|
||||
log.error("_Music:play: failed to create media player")
|
||||
show_warning(None, "Media player", "Unable to create media player")
|
||||
if position:
|
||||
self.player.set_position(position)
|
||||
self.start_dt = start_time
|
||||
|
||||
def set_position(self, position: float) -> None:
|
||||
"""
|
||||
@ -366,85 +377,100 @@ class _Music:
|
||||
volume = Config.VLC_VOLUME_DEFAULT
|
||||
|
||||
self.player.audio_set_volume(volume)
|
||||
# Ensure volume correct
|
||||
# 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).
|
||||
for _ in range(3):
|
||||
current_volume = self.player.audio_get_volume()
|
||||
if current_volume < volume:
|
||||
self.player.audio_set_volume(volume)
|
||||
log.debug(f"Reset from {volume=}")
|
||||
sleep(0.1)
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Immediately stop playing"""
|
||||
|
||||
log.debug(f"Music[{self.name}].stop()")
|
||||
|
||||
self.start_dt = None
|
||||
|
||||
if not self.player:
|
||||
return
|
||||
|
||||
if self.player.is_playing():
|
||||
self.player.stop()
|
||||
self.player.release()
|
||||
self.player = None
|
||||
|
||||
|
||||
class _TrackManager:
|
||||
class RowAndTrack:
|
||||
"""
|
||||
Object to manage active playlist tracks,
|
||||
typically the previous, current and next track.
|
||||
Object to manage playlist rows and tracks.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session: db.Session,
|
||||
player_name: str,
|
||||
track_id: int,
|
||||
row_number: int,
|
||||
) -> None:
|
||||
def __init__(self, playlist_row: PlaylistRows) -> None:
|
||||
"""
|
||||
Initialises data structure.
|
||||
Define a player.
|
||||
Raise ValueError if no track in passed plr.
|
||||
|
||||
The passed PlaylistRows object will include a Tracks object if this
|
||||
row has a track.
|
||||
"""
|
||||
|
||||
track = session.get(Tracks, track_id)
|
||||
if not track:
|
||||
raise ValueError(f"_TrackPlayer: unable to retreived {track_id=}")
|
||||
self.player_name = player_name
|
||||
self.row_number = row_number
|
||||
# Collect playlistrow data
|
||||
self.note = playlist_row.note
|
||||
self.played = playlist_row.played
|
||||
self.playlist_id = playlist_row.playlist_id
|
||||
self.playlistrow_id = playlist_row.id
|
||||
self.row_number = playlist_row.row_number
|
||||
self.track_id = playlist_row.track_id
|
||||
|
||||
# Check file readable
|
||||
if file_is_unreadable(track.path):
|
||||
raise ValueError(f"_TrackManager.__init__: {track.path=} unreadable")
|
||||
# Playlist display data
|
||||
self.row_fg: Optional[str] = None
|
||||
self.row_bg: Optional[str] = None
|
||||
self.note_fg: Optional[str] = None
|
||||
self.note_bg: Optional[str] = None
|
||||
|
||||
self.artist = track.artist
|
||||
self.bitrate = track.bitrate
|
||||
self.duration = track.duration
|
||||
self.fade_at = track.fade_at
|
||||
self.intro = track.intro
|
||||
self.path = track.path
|
||||
self.silence_at = track.silence_at
|
||||
self.start_gap = track.start_gap
|
||||
self.title = track.title
|
||||
self.track_id = track.id
|
||||
# Collect track data if there's a track
|
||||
if playlist_row.track_id:
|
||||
self.artist = playlist_row.track.artist
|
||||
self.bitrate = playlist_row.track.bitrate
|
||||
self.duration = playlist_row.track.duration
|
||||
self.fade_at = playlist_row.track.fade_at
|
||||
self.intro = playlist_row.track.intro
|
||||
if playlist_row.track.playdates:
|
||||
self.lastplayed = max(
|
||||
[a.lastplayed for a in playlist_row.track.playdates]
|
||||
)
|
||||
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.fade_graph: Optional[_FadeCurve] = None
|
||||
self.fade_graph_start_updates: Optional[dt.datetime] = None
|
||||
self.resume_marker: Optional[float]
|
||||
self.resume_marker: Optional[float] = 0.0
|
||||
self.forecast_end_time: Optional[dt.datetime] = None
|
||||
self.forecast_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()
|
||||
|
||||
# Initialise player
|
||||
self.player = _Music(name=player_name)
|
||||
|
||||
# Initialise and add FadeCurve in a thread as it's slow
|
||||
self.fadecurve_thread = QThread()
|
||||
self.worker = _AddFadeCurve(
|
||||
self,
|
||||
track_path=track.path,
|
||||
track_fade_at=track.fade_at,
|
||||
track_silence_at=track.silence_at,
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"<RowAndTrack(playlist_id={self.playlist_id}, "
|
||||
f"row_number={self.row_number}, "
|
||||
f"playlistrow_id={self.playlistrow_id}, "
|
||||
f"note={self.note}, track_id={self.track_id}>"
|
||||
)
|
||||
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:
|
||||
"""
|
||||
@ -457,12 +483,35 @@ class _TrackManager:
|
||||
if self.end_of_track_signalled:
|
||||
return
|
||||
|
||||
if not self.player.is_playing():
|
||||
self.start_time = None
|
||||
if self.fade_graph:
|
||||
self.fade_graph.clear()
|
||||
self.signal_end_of_track()
|
||||
self.end_of_track_signalled = True
|
||||
if self.music.is_playing():
|
||||
return
|
||||
|
||||
self.start_time = None
|
||||
if self.fade_graph:
|
||||
self.fade_graph.clear()
|
||||
# Ensure that player is released
|
||||
self.music.fade(0)
|
||||
self.signals.track_ended_signal.emit()
|
||||
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:
|
||||
"""
|
||||
@ -470,16 +519,16 @@ class _TrackManager:
|
||||
"""
|
||||
|
||||
if enable:
|
||||
self.player.set_volume(volume=Config.VLC_VOLUME_DROP3db, set_default=False)
|
||||
self.music.set_volume(volume=Config.VLC_VOLUME_DROP3db, set_default=False)
|
||||
else:
|
||||
self.player.set_volume(volume=Config.VLC_VOLUME_DEFAULT, set_default=False)
|
||||
self.music.set_volume(volume=Config.VLC_VOLUME_DEFAULT, set_default=False)
|
||||
|
||||
def fade(self, fade_seconds: int = Config.FADEOUT_SECONDS) -> None:
|
||||
"""Fade music"""
|
||||
|
||||
self.resume_marker = self.player.get_position()
|
||||
self.player.fade(fade_seconds)
|
||||
self.signal_end_of_track()
|
||||
self.resume_marker = self.music.get_position()
|
||||
self.music.fade(fade_seconds)
|
||||
self.signals.track_ended_signal.emit()
|
||||
|
||||
def is_playing(self) -> bool:
|
||||
"""
|
||||
@ -489,30 +538,30 @@ class _TrackManager:
|
||||
if self.start_time is None:
|
||||
return False
|
||||
|
||||
return self.player.is_playing()
|
||||
return self.music.is_playing()
|
||||
|
||||
def move_back(self, ms: int = Config.PREVIEW_BACK_MS) -> None:
|
||||
"""
|
||||
Rewind player by ms milliseconds
|
||||
"""
|
||||
|
||||
self.player.adjust_by_ms(ms * -1)
|
||||
self.music.adjust_by_ms(ms * -1)
|
||||
|
||||
def move_forward(self, ms: int = Config.PREVIEW_ADVANCE_MS) -> None:
|
||||
"""
|
||||
Rewind player by ms milliseconds
|
||||
"""
|
||||
|
||||
self.player.adjust_by_ms(ms)
|
||||
self.music.adjust_by_ms(ms)
|
||||
|
||||
def play(self, position: Optional[float] = None) -> None:
|
||||
"""Play track"""
|
||||
|
||||
log.debug(f"issue223: _TrackManager: play {self.track_id=}")
|
||||
now = dt.datetime.now()
|
||||
self.start_time = now
|
||||
|
||||
self.player.play(self.path, start_time=now, position=position)
|
||||
# Initialise player
|
||||
self.music.play(self.path, start_time=now, position=position)
|
||||
|
||||
self.end_time = now + dt.timedelta(milliseconds=self.duration)
|
||||
|
||||
@ -530,21 +579,47 @@ class _TrackManager:
|
||||
Restart player
|
||||
"""
|
||||
|
||||
self.player.adjust_by_ms(self.time_playing() * -1)
|
||||
self.music.adjust_by_ms(self.time_playing() * -1)
|
||||
|
||||
def signal_end_of_track(self) -> None:
|
||||
def set_forecast_start_time(
|
||||
self, modified_rows: list[int], start: Optional[dt.datetime]
|
||||
) -> Optional[dt.datetime]:
|
||||
"""
|
||||
Send end of track signal unless we are a preview player
|
||||
Set forecast start time for this row
|
||||
|
||||
Update passed modified rows list if we changed the row.
|
||||
|
||||
Return new start time
|
||||
"""
|
||||
|
||||
self.signals.track_ended_signal.emit()
|
||||
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:
|
||||
"""
|
||||
Stop this track playing
|
||||
"""
|
||||
|
||||
self.resume_marker = self.player.get_position()
|
||||
self.resume_marker = self.music.get_position()
|
||||
self.fade(fade_seconds)
|
||||
|
||||
# Reset fade graph
|
||||
@ -559,7 +634,7 @@ class _TrackManager:
|
||||
if self.start_time is None:
|
||||
return 0
|
||||
|
||||
return self.player.get_playtime()
|
||||
return self.music.get_playtime()
|
||||
|
||||
def time_remaining_intro(self) -> int:
|
||||
"""
|
||||
@ -611,46 +686,39 @@ class _TrackManager:
|
||||
|
||||
self.fade_graph.tick(self.time_playing())
|
||||
|
||||
|
||||
class MainTrackManager(_TrackManager):
|
||||
"""
|
||||
Manage playing tracks from the playlist with associated data
|
||||
"""
|
||||
|
||||
def __init__(self, session: db.Session, plr_id: int) -> None:
|
||||
def update_playlist_and_row(self, session: Session) -> None:
|
||||
"""
|
||||
Set up manager for playlist tracks
|
||||
Update local playlist_id and row_number from playlistrow_id
|
||||
"""
|
||||
|
||||
# Ensure we have a track
|
||||
plr = session.get(PlaylistRows, plr_id)
|
||||
plr = session.get(PlaylistRows, self.playlistrow_id)
|
||||
if not plr:
|
||||
raise ValueError(f"PlaylistTrack: unable to retreive plr {plr_id=}")
|
||||
|
||||
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}>"
|
||||
)
|
||||
raise ApplicationError(f"(Can't retrieve PlaylistRows entry, {self=}")
|
||||
self.playlist_id = plr.playlist_id
|
||||
self.row_number = plr.row_number
|
||||
|
||||
|
||||
class TrackSequence:
|
||||
next: Optional[MainTrackManager] = None
|
||||
current: Optional[MainTrackManager] = None
|
||||
previous: Optional[MainTrackManager] = None
|
||||
next: Optional[RowAndTrack] = None
|
||||
current: Optional[RowAndTrack] = None
|
||||
previous: Optional[RowAndTrack] = 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()
|
||||
4191
app/musicmuster.py
4191
app/musicmuster.py
File diff suppressed because it is too large
Load Diff
@ -1,305 +0,0 @@
|
||||
#!/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()
|
||||
1655
app/playlistmodel.py
1655
app/playlistmodel.py
File diff suppressed because it is too large
Load Diff
660
app/playlists.py
660
app/playlists.py
File diff suppressed because it is too large
Load Diff
290
app/querylistmodel.py
Normal file
290
app/querylistmodel.py
Normal file
@ -0,0 +1,290 @@
|
||||
# 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)
|
||||
]
|
||||
)
|
||||
)
|
||||
94
app/ui/dlgQuery.ui
Normal file
94
app/ui/dlgQuery.ui
Normal file
@ -0,0 +1,94 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>queryDialog</class>
|
||||
<widget class="QDialog" name="queryDialog">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>762</width>
|
||||
<height>686</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Query</string>
|
||||
</property>
|
||||
<widget class="QTableView" name="tableView">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>10</x>
|
||||
<y>65</y>
|
||||
<width>741</width>
|
||||
<height>561</height>
|
||||
</rect>
|
||||
</property>
|
||||
</widget>
|
||||
<widget class="QLabel" name="label">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>20</x>
|
||||
<y>10</y>
|
||||
<width>61</width>
|
||||
<height>24</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Query:</string>
|
||||
</property>
|
||||
</widget>
|
||||
<widget class="QComboBox" name="cboQuery">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>80</x>
|
||||
<y>10</y>
|
||||
<width>221</width>
|
||||
<height>32</height>
|
||||
</rect>
|
||||
</property>
|
||||
</widget>
|
||||
<widget class="QPushButton" name="btnAddTracks">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>530</x>
|
||||
<y>640</y>
|
||||
<width>102</width>
|
||||
<height>36</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Add &tracks</string>
|
||||
</property>
|
||||
</widget>
|
||||
<widget class="QLabel" name="lblDescription">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>330</x>
|
||||
<y>10</y>
|
||||
<width>401</width>
|
||||
<height>46</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>TextLabel</string>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop</set>
|
||||
</property>
|
||||
</widget>
|
||||
<widget class="QPushButton" name="pushButton">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>650</x>
|
||||
<y>640</y>
|
||||
<width>102</width>
|
||||
<height>36</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Close</string>
|
||||
</property>
|
||||
</widget>
|
||||
</widget>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
45
app/ui/dlgQuery_ui.py
Normal file
45
app/ui/dlgQuery_ui.py
Normal file
@ -0,0 +1,45 @@
|
||||
# Form implementation generated from reading ui file 'app/ui/dlgQuery.ui'
|
||||
#
|
||||
# Created by: PyQt6 UI code generator 6.8.1
|
||||
#
|
||||
# WARNING: Any manual changes made to this file will be lost when pyuic6 is
|
||||
# run again. Do not edit this file unless you know what you are doing.
|
||||
|
||||
|
||||
from PyQt6 import QtCore, QtGui, QtWidgets
|
||||
|
||||
|
||||
class Ui_queryDialog(object):
|
||||
def setupUi(self, queryDialog):
|
||||
queryDialog.setObjectName("queryDialog")
|
||||
queryDialog.resize(762, 686)
|
||||
self.tableView = QtWidgets.QTableView(parent=queryDialog)
|
||||
self.tableView.setGeometry(QtCore.QRect(10, 65, 741, 561))
|
||||
self.tableView.setObjectName("tableView")
|
||||
self.label = QtWidgets.QLabel(parent=queryDialog)
|
||||
self.label.setGeometry(QtCore.QRect(20, 10, 61, 24))
|
||||
self.label.setObjectName("label")
|
||||
self.cboQuery = QtWidgets.QComboBox(parent=queryDialog)
|
||||
self.cboQuery.setGeometry(QtCore.QRect(80, 10, 221, 32))
|
||||
self.cboQuery.setObjectName("cboQuery")
|
||||
self.btnAddTracks = QtWidgets.QPushButton(parent=queryDialog)
|
||||
self.btnAddTracks.setGeometry(QtCore.QRect(530, 640, 102, 36))
|
||||
self.btnAddTracks.setObjectName("btnAddTracks")
|
||||
self.lblDescription = QtWidgets.QLabel(parent=queryDialog)
|
||||
self.lblDescription.setGeometry(QtCore.QRect(330, 10, 401, 46))
|
||||
self.lblDescription.setAlignment(QtCore.Qt.AlignmentFlag.AlignLeading|QtCore.Qt.AlignmentFlag.AlignLeft|QtCore.Qt.AlignmentFlag.AlignTop)
|
||||
self.lblDescription.setObjectName("lblDescription")
|
||||
self.pushButton = QtWidgets.QPushButton(parent=queryDialog)
|
||||
self.pushButton.setGeometry(QtCore.QRect(650, 640, 102, 36))
|
||||
self.pushButton.setObjectName("pushButton")
|
||||
|
||||
self.retranslateUi(queryDialog)
|
||||
QtCore.QMetaObject.connectSlotsByName(queryDialog)
|
||||
|
||||
def retranslateUi(self, queryDialog):
|
||||
_translate = QtCore.QCoreApplication.translate
|
||||
queryDialog.setWindowTitle(_translate("queryDialog", "Query"))
|
||||
self.label.setText(_translate("queryDialog", "Query:"))
|
||||
self.btnAddTracks.setText(_translate("queryDialog", "Add &tracks"))
|
||||
self.lblDescription.setText(_translate("queryDialog", "TextLabel"))
|
||||
self.pushButton.setText(_translate("queryDialog", "Close"))
|
||||
BIN
app/ui/green-circle.png
Normal file
BIN
app/ui/green-circle.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.6 KiB |
@ -1,5 +1,8 @@
|
||||
<RCC>
|
||||
<qresource prefix="icons">
|
||||
<file>yellow-circle.png</file>
|
||||
<file>redstar.png</file>
|
||||
<file>green-circle.png</file>
|
||||
<file>star.png</file>
|
||||
<file>star_empty.png</file>
|
||||
<file>record-red-button.png</file>
|
||||
|
||||
2260
app/ui/icons_rc.py
2260
app/ui/icons_rc.py
File diff suppressed because it is too large
Load Diff
@ -967,68 +967,70 @@ padding-left: 8px;</string>
|
||||
</property>
|
||||
<widget class="QMenu" name="menuFile">
|
||||
<property name="title">
|
||||
<string>&Playlists</string>
|
||||
<string>&Playlist</string>
|
||||
</property>
|
||||
<addaction name="actionNewPlaylist"/>
|
||||
<addaction name="actionNew_from_template"/>
|
||||
<addaction name="actionOpenPlaylist"/>
|
||||
<addaction name="actionClosePlaylist"/>
|
||||
<addaction name="actionRenamePlaylist"/>
|
||||
<addaction name="actionDeletePlaylist"/>
|
||||
<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="separator"/>
|
||||
<addaction name="actionMoveSelected"/>
|
||||
<addaction name="actionMoveUnplayed"/>
|
||||
<addaction name="actionDownload_CSV_of_played_tracks"/>
|
||||
<addaction name="actionSave_as_template"/>
|
||||
<addaction name="separator"/>
|
||||
<addaction name="actionReplace_files"/>
|
||||
<addaction name="separator"/>
|
||||
<addaction name="actionE_xit"/>
|
||||
<addaction name="action_Clear_selection"/>
|
||||
</widget>
|
||||
<widget class="QMenu" name="menuPlaylist">
|
||||
<property name="title">
|
||||
<string>Sho&wtime</string>
|
||||
<string>&File</string>
|
||||
</property>
|
||||
<addaction name="separator"/>
|
||||
<addaction name="separator"/>
|
||||
<addaction name="actionOpenPlaylist"/>
|
||||
<addaction name="actionNewPlaylist"/>
|
||||
<addaction name="actionClosePlaylist"/>
|
||||
<addaction name="actionRenamePlaylist"/>
|
||||
<addaction name="actionDeletePlaylist"/>
|
||||
<addaction name="separator"/>
|
||||
<addaction name="actionOpenQuerylist"/>
|
||||
<addaction name="actionManage_querylists"/>
|
||||
<addaction name="separator"/>
|
||||
<addaction name="actionSave_as_template"/>
|
||||
<addaction name="actionManage_templates"/>
|
||||
<addaction name="separator"/>
|
||||
<addaction name="actionImport_files"/>
|
||||
<addaction name="separator"/>
|
||||
<addaction name="actionE_xit"/>
|
||||
</widget>
|
||||
<widget class="QMenu" name="menuSearc_h">
|
||||
<property name="title">
|
||||
<string>&Music</string>
|
||||
</property>
|
||||
<addaction name="actionSetNext"/>
|
||||
<addaction name="actionPlay_next"/>
|
||||
<addaction name="actionFade"/>
|
||||
<addaction name="actionStop"/>
|
||||
<addaction name="actionResume"/>
|
||||
<addaction name="separator"/>
|
||||
<addaction name="actionSkipToNext"/>
|
||||
<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>&Search</string>
|
||||
</property>
|
||||
<addaction name="actionSearch"/>
|
||||
<addaction name="separator"/>
|
||||
<addaction name="actionSearch_title_in_Wikipedia"/>
|
||||
<addaction name="actionSearch_title_in_Songfacts"/>
|
||||
</widget>
|
||||
<widget class="QMenu" name="menuHelp">
|
||||
<property name="title">
|
||||
<string>&Help</string>
|
||||
<string>Help</string>
|
||||
</property>
|
||||
<addaction name="action_About"/>
|
||||
<addaction name="actionDebug"/>
|
||||
</widget>
|
||||
<addaction name="menuFile"/>
|
||||
<addaction name="menuPlaylist"/>
|
||||
<addaction name="menuFile"/>
|
||||
<addaction name="menuSearc_h"/>
|
||||
<addaction name="menuHelp"/>
|
||||
</widget>
|
||||
@ -1305,9 +1307,9 @@ padding-left: 8px;</string>
|
||||
<string>Save as template...</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionNew_from_template">
|
||||
<action name="actionManage_templates">
|
||||
<property name="text">
|
||||
<string>New from template...</string>
|
||||
<string>Manage templates...</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionDebug">
|
||||
@ -1365,9 +1367,19 @@ padding-left: 8px;</string>
|
||||
<string>Select duplicate rows...</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionReplace_files">
|
||||
<action name="actionImport_files">
|
||||
<property name="text">
|
||||
<string>Replace files...</string>
|
||||
<string>Import files...</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionOpenQuerylist">
|
||||
<property name="text">
|
||||
<string>Open &querylist...</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionManage_querylists">
|
||||
<property name="text">
|
||||
<string>Manage querylists...</string>
|
||||
</property>
|
||||
</action>
|
||||
</widget>
|
||||
|
||||
589
app/ui/main_window_footer.ui
Normal file
589
app/ui/main_window_footer.ui
Normal file
@ -0,0 +1,589 @@
|
||||
<?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><<</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>>></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><</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>></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>
|
||||
274
app/ui/main_window_footer_ui.py
Normal file
274
app/ui/main_window_footer_ui.py
Normal file
@ -0,0 +1,274 @@
|
||||
# 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
|
||||
314
app/ui/main_window_header.ui
Normal file
314
app/ui/main_window_header.ui
Normal file
@ -0,0 +1,314 @@
|
||||
<?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>
|
||||
178
app/ui/main_window_header_ui.py
Normal file
178
app/ui/main_window_header_ui.py
Normal file
@ -0,0 +1,178 @@
|
||||
# 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"))
|
||||
42
app/ui/main_window_playlist.ui
Normal file
42
app/ui/main_window_playlist.ui
Normal file
@ -0,0 +1,42 @@
|
||||
<?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>
|
||||
34
app/ui/main_window_playlist_ui.py
Normal file
34
app/ui/main_window_playlist_ui.py
Normal file
@ -0,0 +1,34 @@
|
||||
# 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"))
|
||||
@ -1,844 +0,0 @@
|
||||
# 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 §ion 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 # type: ignore
|
||||
BIN
app/ui/redstar.png
Normal file
BIN
app/ui/redstar.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
BIN
app/ui/yellow-circle.png
Normal file
BIN
app/ui/yellow-circle.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.6 KiB |
@ -16,7 +16,7 @@ from log import log
|
||||
from models import Tracks
|
||||
|
||||
|
||||
def check_db(session: Session):
|
||||
def check_db(session: Session) -> None:
|
||||
"""
|
||||
Database consistency check.
|
||||
|
||||
@ -84,7 +84,7 @@ def check_db(session: Session):
|
||||
print("There were more paths than listed that were not found")
|
||||
|
||||
|
||||
def update_bitrates(session: Session):
|
||||
def update_bitrates(session: Session) -> None:
|
||||
"""
|
||||
Update bitrates on all tracks in database
|
||||
"""
|
||||
@ -92,6 +92,6 @@ def update_bitrates(session: Session):
|
||||
for track in Tracks.get_all(session):
|
||||
try:
|
||||
t = get_tags(track.path)
|
||||
track.bitrate = t["bitrate"]
|
||||
track.bitrate = t.bitrate
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
|
||||
29
app/vlcmanager.py
Normal file
29
app/vlcmanager.py
Normal file
@ -0,0 +1,29 @@
|
||||
# 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
|
||||
@ -22,8 +22,9 @@ def fade_point(audio_segment, fade_threshold=-12, chunk_size=10):
|
||||
print(f"{max_vol=}")
|
||||
fade_threshold = max_vol
|
||||
while (
|
||||
audio_segment[trim_ms:trim_ms + chunk_size].dBFS < fade_threshold
|
||||
and trim_ms > 0): # noqa W503
|
||||
audio_segment[trim_ms : trim_ms + chunk_size].dBFS < fade_threshold
|
||||
and trim_ms > 0
|
||||
): # noqa W503
|
||||
trim_ms -= chunk_size
|
||||
|
||||
# if there is no trailing silence, return lenght of track (it's less
|
||||
|
||||
125
archive/proxymodel.py
Executable file
125
archive/proxymodel.py
Executable file
@ -0,0 +1,125 @@
|
||||
#!/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())
|
||||
|
||||
@ -1,73 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""Tests the audacity pipe.
|
||||
Keep pipe_test.py short!!
|
||||
You can make more complicated longer tests to test other functionality
|
||||
or to generate screenshots etc in other scripts.
|
||||
Make sure Audacity is running first and that mod-script-pipe is enabled
|
||||
before running this script.
|
||||
Requires Python 2.7 or later. Python 3 is strongly recommended.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
if sys.platform == 'win32':
|
||||
print("pipe-test.py, running on windows")
|
||||
TONAME = '\\\\.\\pipe\\ToSrvPipe'
|
||||
FROMNAME = '\\\\.\\pipe\\FromSrvPipe'
|
||||
EOL = '\r\n\0'
|
||||
else:
|
||||
print("pipe-test.py, running on linux or mac")
|
||||
TONAME = '/tmp/audacity_script_pipe.to.' + str(os.getuid())
|
||||
FROMNAME = '/tmp/audacity_script_pipe.from.' + str(os.getuid())
|
||||
EOL = '\n'
|
||||
|
||||
print("Write to \"" + TONAME + "\"")
|
||||
if not os.path.exists(TONAME):
|
||||
print(" does not exist. Ensure Audacity is running with mod-script-pipe.")
|
||||
sys.exit()
|
||||
|
||||
print("Read from \"" + FROMNAME + "\"")
|
||||
if not os.path.exists(FROMNAME):
|
||||
print(" does not exist. Ensure Audacity is running with mod-script-pipe.")
|
||||
sys.exit()
|
||||
|
||||
print("-- Both pipes exist. Good.")
|
||||
|
||||
TOFILE = open(TONAME, 'w')
|
||||
print("-- File to write to has been opened")
|
||||
FROMFILE = open(FROMNAME, 'rt')
|
||||
print("-- File to read from has now been opened too\r\n")
|
||||
|
||||
|
||||
def send_command(command):
|
||||
"""Send a single command."""
|
||||
print("Send: >>> \n"+command)
|
||||
TOFILE.write(command + EOL)
|
||||
TOFILE.flush()
|
||||
|
||||
|
||||
def get_response():
|
||||
"""Return the command response."""
|
||||
result = ''
|
||||
line = ''
|
||||
while True:
|
||||
result += line
|
||||
line = FROMFILE.readline()
|
||||
if line == '\n' and len(result) > 0:
|
||||
break
|
||||
return result
|
||||
|
||||
|
||||
def do_command(command):
|
||||
"""Send one command, and return the response."""
|
||||
send_command(command)
|
||||
response = get_response()
|
||||
print("Rcvd: <<< \n" + response)
|
||||
return response
|
||||
|
||||
|
||||
do_command('Import2: Filename=/home/kae/git/musicmuster/archive/boot.flac')
|
||||
@ -1 +0,0 @@
|
||||
Run Flake8 and Black
|
||||
@ -1,4 +1,7 @@
|
||||
"""${message}
|
||||
<%!
|
||||
import re
|
||||
|
||||
%>"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
@ -16,9 +19,27 @@ branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade():
|
||||
${upgrades if upgrades else "pass"}
|
||||
def upgrade(engine_name: str) -> None:
|
||||
globals()["upgrade_%s" % engine_name]()
|
||||
|
||||
|
||||
def downgrade():
|
||||
${downgrades if downgrades else "pass"}
|
||||
def downgrade(engine_name: str) -> None:
|
||||
globals()["downgrade_%s" % engine_name]()
|
||||
|
||||
<%
|
||||
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
|
||||
|
||||
@ -0,0 +1,58 @@
|
||||
"""add favouirit to playlists
|
||||
|
||||
Revision ID: 04df697e40cd
|
||||
Revises: 33c04e3c12c8
|
||||
Create Date: 2025-02-22 20:20:45.030024
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import mysql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '04df697e40cd'
|
||||
down_revision = '33c04e3c12c8'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade(engine_name: str) -> None:
|
||||
globals()["upgrade_%s" % engine_name]()
|
||||
|
||||
|
||||
def downgrade(engine_name: str) -> None:
|
||||
globals()["downgrade_%s" % engine_name]()
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def upgrade_() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('notecolours', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('strip_substring', sa.Boolean(), nullable=False))
|
||||
batch_op.create_index(batch_op.f('ix_notecolours_substring'), ['substring'], unique=False)
|
||||
|
||||
with op.batch_alter_table('playlist_rows', schema=None) as batch_op:
|
||||
batch_op.drop_constraint('playlist_rows_ibfk_1', type_='foreignkey')
|
||||
|
||||
with op.batch_alter_table('playlists', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('favourite', sa.Boolean(), nullable=False))
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade_() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('playlists', schema=None) as batch_op:
|
||||
batch_op.drop_column('favourite')
|
||||
|
||||
with op.batch_alter_table('playlist_rows', schema=None) as batch_op:
|
||||
batch_op.create_foreign_key('playlist_rows_ibfk_1', 'tracks', ['track_id'], ['id'])
|
||||
|
||||
with op.batch_alter_table('notecolours', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_notecolours_substring'))
|
||||
batch_op.drop_column('strip_substring')
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
@ -1,32 +0,0 @@
|
||||
"""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 ###
|
||||
@ -1,32 +0,0 @@
|
||||
"""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 ###
|
||||
46
migrations/versions/164bd5ef3074_remove_mtime_from_tracks.py
Normal file
46
migrations/versions/164bd5ef3074_remove_mtime_from_tracks.py
Normal file
@ -0,0 +1,46 @@
|
||||
"""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 ###
|
||||
|
||||
@ -1,40 +0,0 @@
|
||||
"""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 ###
|
||||
@ -1,34 +0,0 @@
|
||||
"""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 ###
|
||||
@ -1,34 +0,0 @@
|
||||
"""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 ###
|
||||
@ -1,24 +0,0 @@
|
||||
"""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)
|
||||
@ -1,110 +0,0 @@
|
||||
"""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 ###
|
||||
|
||||
@ -1,30 +0,0 @@
|
||||
"""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 ###
|
||||
@ -0,0 +1,52 @@
|
||||
"""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 ###
|
||||
|
||||
@ -1,72 +0,0 @@
|
||||
"""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 ###
|
||||
@ -1,54 +0,0 @@
|
||||
"""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 ###
|
||||
@ -1,26 +0,0 @@
|
||||
"""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')
|
||||
@ -1,32 +0,0 @@
|
||||
"""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 ###
|
||||
47
migrations/versions/4fc2a9a82ab0_create_queries_table.py
Normal file
47
migrations/versions/4fc2a9a82ab0_create_queries_table.py
Normal file
@ -0,0 +1,47 @@
|
||||
"""create queries table
|
||||
|
||||
Revision ID: 4fc2a9a82ab0
|
||||
Revises: ab475332d873
|
||||
Create Date: 2025-02-26 13:13:25.118489
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import dbtables
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '4fc2a9a82ab0'
|
||||
down_revision = 'ab475332d873'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade(engine_name: str) -> None:
|
||||
globals()["upgrade_%s" % engine_name]()
|
||||
|
||||
|
||||
def downgrade(engine_name: str) -> None:
|
||||
globals()["downgrade_%s" % engine_name]()
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def upgrade_() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('queries',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('name', sa.String(length=128), nullable=False),
|
||||
sa.Column('filter_data', dbtables.JSONEncodedDict(), nullable=False),
|
||||
sa.Column('favourite', sa.Boolean(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade_() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table('queries')
|
||||
# ### end Alembic commands ###
|
||||
|
||||
@ -1,34 +0,0 @@
|
||||
"""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 ###
|
||||
@ -1,30 +0,0 @@
|
||||
"""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 ###
|
||||
@ -1,24 +0,0 @@
|
||||
"""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
|
||||
@ -1,60 +0,0 @@
|
||||
"""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 ###
|
||||
@ -1,41 +0,0 @@
|
||||
"""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 ###
|
||||
75
migrations/versions/708a21f5c271_initial_migration.py
Normal file
75
migrations/versions/708a21f5c271_initial_migration.py
Normal file
@ -0,0 +1,75 @@
|
||||
"""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 ###
|
||||
|
||||
@ -1,34 +0,0 @@
|
||||
"""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 ###
|
||||
@ -0,0 +1,44 @@
|
||||
"""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 ###
|
||||
|
||||
@ -1,36 +0,0 @@
|
||||
"""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 ###
|
||||
46
migrations/versions/ab475332d873_fix_playdates_cascades.py
Normal file
46
migrations/versions/ab475332d873_fix_playdates_cascades.py
Normal file
@ -0,0 +1,46 @@
|
||||
"""fix playlist cascades
|
||||
|
||||
Revision ID: ab475332d873
|
||||
Revises: 04df697e40cd
|
||||
Create Date: 2025-02-26 13:11:15.417278
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'ab475332d873'
|
||||
down_revision = '04df697e40cd'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade(engine_name: str) -> None:
|
||||
globals()["upgrade_%s" % engine_name]()
|
||||
|
||||
|
||||
def downgrade(engine_name: str) -> None:
|
||||
globals()["downgrade_%s" % engine_name]()
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def upgrade_() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('playdates', schema=None) as batch_op:
|
||||
batch_op.drop_constraint('fk_playdates_track_id_tracks', type_='foreignkey')
|
||||
batch_op.create_foreign_key(None, 'tracks', ['track_id'], ['id'], ondelete='CASCADE')
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade_() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('playdates', schema=None) as batch_op:
|
||||
batch_op.drop_constraint(None, type_='foreignkey')
|
||||
batch_op.create_foreign_key('fk_playdates_track_id_tracks', 'tracks', ['track_id'], ['id'])
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
@ -1,37 +0,0 @@
|
||||
"""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 ###
|
||||
@ -1,32 +0,0 @@
|
||||
"""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 ###
|
||||
@ -1,32 +0,0 @@
|
||||
"""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 ###
|
||||
@ -1,51 +0,0 @@
|
||||
"""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 ###
|
||||
@ -1,28 +0,0 @@
|
||||
"""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 ###
|
||||
@ -1,28 +0,0 @@
|
||||
"""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 ###
|
||||
@ -1,63 +0,0 @@
|
||||
"""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 ###
|
||||
@ -1,28 +0,0 @@
|
||||
"""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
6
mmimport
@ -1,6 +0,0 @@
|
||||
#!/bin/bash
|
||||
# cd /home/kae/mm
|
||||
# MYSQL_CONNECT="mysql+mysqldb://musicmuster:musicmuster@localhost/musicmuster_prod" ROOT="/home/kae/music" direnv exec .
|
||||
for file in "$@"; do
|
||||
app/songdb.py -i "$file"
|
||||
done
|
||||
2039
poetry.lock
generated
2039
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
105
pyproject.toml
105
pyproject.toml
@ -1,57 +1,73 @@
|
||||
[tool.poetry]
|
||||
[project]
|
||||
name = "musicmuster"
|
||||
version = "1.7.5"
|
||||
version = "4.1.10"
|
||||
description = "Music player for internet radio"
|
||||
authors = ["Keith Edmunds <kae@midnighthax.com>"]
|
||||
authors = [{ name = "Keith Edmunds", email = "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",
|
||||
]
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.11"
|
||||
tinytag = "^1.10.1"
|
||||
SQLAlchemy = "^2.0.29"
|
||||
python-vlc = "^3.0.20123"
|
||||
mysqlclient = "^2.2.4"
|
||||
mutagen = "^1.47.0"
|
||||
alembic = "^1.13.1"
|
||||
psutil = "^5.9.8"
|
||||
pydub = "^0.25.1"
|
||||
types-psutil = "^5.9.5.20240423"
|
||||
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"
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"flakehell>=0.9.0,<0.10",
|
||||
"ipdb>=0.13.9,<0.14",
|
||||
"line-profiler>=4.2.0,<5",
|
||||
"mypy>=1.15.0,<2",
|
||||
"pudb",
|
||||
"pydub-stubs>=0.25.1,<0.26",
|
||||
"pytest>=8.3.4,<9",
|
||||
"pytest-qt>=4.4.0,<5",
|
||||
"black>=25.1.0,<26",
|
||||
"pytest-cov>=6.0.0,<7",
|
||||
]
|
||||
|
||||
[tool.poetry.dev-dependencies]
|
||||
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"
|
||||
[tool.uv]
|
||||
package = false
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core>=1.0.0"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.mypy]
|
||||
mypy_path = "/home/kae/git/musicmuster/app"
|
||||
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]
|
||||
addopts = "--exitfirst --showlocals --capture=no"
|
||||
@ -62,3 +78,4 @@ filterwarnings = ["ignore:'audioop' is deprecated", "ignore:pkg_resources"]
|
||||
exclude = ["migrations", "app/ui", "archive"]
|
||||
paths = ["app"]
|
||||
make_whitelist = true
|
||||
|
||||
|
||||
489
tests/test_file_importer.py
Normal file
489
tests/test_file_importer.py
Normal file
@ -0,0 +1,489 @@
|
||||
"""
|
||||
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) == []
|
||||
@ -55,8 +55,8 @@ class TestMMHelpers(unittest.TestCase):
|
||||
with open(test_track_data) as f:
|
||||
testdata = eval(f.read())
|
||||
|
||||
assert tags["artist"] == testdata["artist"]
|
||||
assert tags["title"] == testdata["title"]
|
||||
assert tags.artist == testdata["artist"]
|
||||
assert tags.title == testdata["title"]
|
||||
|
||||
def test_get_relative_date(self):
|
||||
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)
|
||||
assert get_relative_date(today_at_10, today_at_11) == "Today 10:00"
|
||||
eight_days_ago = today_at_10 - dt.timedelta(days=8)
|
||||
assert get_relative_date(eight_days_ago, today_at_11) == "1 week, 1 day ago"
|
||||
assert get_relative_date(eight_days_ago, today_at_11) == "1 week, 1 day"
|
||||
sixteen_days_ago = today_at_10 - dt.timedelta(days=16)
|
||||
assert get_relative_date(sixteen_days_ago, today_at_11) == "2 weeks, 2 days ago"
|
||||
assert get_relative_date(sixteen_days_ago, today_at_11) == "2 weeks, 2 days"
|
||||
|
||||
def test_leading_silence(self):
|
||||
test_track_path = "testdata/isa.mp3"
|
||||
|
||||
@ -21,7 +21,9 @@ from app.models import (
|
||||
|
||||
class TestMMModels(unittest.TestCase):
|
||||
def setUp(self):
|
||||
"""Runs before each test"""
|
||||
db.create_all()
|
||||
NoteColours.invalidate_cache()
|
||||
|
||||
with db.Session() as session:
|
||||
track1_path = "testdata/isa.mp3"
|
||||
@ -31,6 +33,7 @@ class TestMMModels(unittest.TestCase):
|
||||
self.track2 = Tracks(session, **helpers.get_all_track_metadata(track2_path))
|
||||
|
||||
def tearDown(self):
|
||||
"""Runs after each test"""
|
||||
db.drop_all()
|
||||
|
||||
def test_track_repr(self):
|
||||
@ -70,7 +73,7 @@ class TestMMModels(unittest.TestCase):
|
||||
NoteColours(session, substring="substring", colour=note_colour)
|
||||
|
||||
result = NoteColours.get_colour(session, "xyz")
|
||||
assert result is None
|
||||
assert result == ""
|
||||
|
||||
def test_notecolours_get_colour_match(self):
|
||||
note_colour = "#4bcdef"
|
||||
@ -108,7 +111,7 @@ class TestMMModels(unittest.TestCase):
|
||||
TEMPLATE_NAME = "my template"
|
||||
|
||||
with db.Session() as session:
|
||||
playlist = Playlists(session, "my playlist")
|
||||
playlist = Playlists(session, "my playlist", template_id=0)
|
||||
assert playlist
|
||||
# test repr
|
||||
_ = str(playlist)
|
||||
@ -119,23 +122,18 @@ class TestMMModels(unittest.TestCase):
|
||||
# create template
|
||||
Playlists.save_as_template(session, playlist.id, TEMPLATE_NAME)
|
||||
|
||||
# test create template
|
||||
_ = Playlists.create_playlist_from_template(
|
||||
session, playlist, "my new name"
|
||||
)
|
||||
|
||||
# get all templates
|
||||
all_templates = Playlists.get_all_templates(session)
|
||||
assert len(all_templates) == 1
|
||||
# Save as template creates new playlist
|
||||
assert all_templates[0] != playlist
|
||||
# test delete playlist
|
||||
playlist.delete(session)
|
||||
session.delete(playlist)
|
||||
|
||||
def test_playlist_open_and_close(self):
|
||||
# We need a playlist
|
||||
with db.Session() as session:
|
||||
playlist = Playlists(session, "my playlist")
|
||||
playlist = Playlists(session, "my playlist", template_id=0)
|
||||
|
||||
assert len(Playlists.get_open(session)) == 0
|
||||
assert len(Playlists.get_closed(session)) == 1
|
||||
@ -155,8 +153,8 @@ class TestMMModels(unittest.TestCase):
|
||||
p1_name = "playlist one"
|
||||
p2_name = "playlist two"
|
||||
with db.Session() as session:
|
||||
playlist1 = Playlists(session, p1_name)
|
||||
_ = Playlists(session, p2_name)
|
||||
playlist1 = Playlists(session, p1_name, template_id=0)
|
||||
_ = Playlists(session, p2_name, template_id=0)
|
||||
|
||||
all_playlists = Playlists.get_all(session)
|
||||
assert len(all_playlists) == 2
|
||||
@ -205,7 +203,7 @@ class TestMMModels(unittest.TestCase):
|
||||
nc = NoteColours(session, substring="x", colour="x")
|
||||
_ = str(nc)
|
||||
|
||||
def test_get_colour(self):
|
||||
def test_get_colour_1(self):
|
||||
"""Test for errors in execution"""
|
||||
|
||||
GOOD_STRING = "cantelope"
|
||||
@ -218,22 +216,42 @@ class TestMMModels(unittest.TestCase):
|
||||
session, substring=SUBSTR, colour=COLOUR, is_casesensitive=True
|
||||
)
|
||||
|
||||
session.commit()
|
||||
_ = nc1.get_colour(session, "")
|
||||
colour = nc1.get_colour(session, GOOD_STRING)
|
||||
assert colour == COLOUR
|
||||
|
||||
colour = nc1.get_colour(session, BAD_STRING)
|
||||
assert colour is None
|
||||
assert colour == ""
|
||||
|
||||
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(
|
||||
session, substring=".*" + SUBSTR, colour=COLOUR, is_regex=True
|
||||
)
|
||||
session.commit()
|
||||
colour = nc2.get_colour(session, GOOD_STRING)
|
||||
assert colour == COLOUR
|
||||
|
||||
colour = nc2.get_colour(session, BAD_STRING)
|
||||
assert colour is None
|
||||
assert colour == ""
|
||||
|
||||
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(
|
||||
session,
|
||||
substring=".*" + SUBSTR,
|
||||
@ -241,12 +259,13 @@ class TestMMModels(unittest.TestCase):
|
||||
is_regex=True,
|
||||
is_casesensitive=True,
|
||||
)
|
||||
session.commit()
|
||||
|
||||
colour = nc3.get_colour(session, GOOD_STRING)
|
||||
assert colour == COLOUR
|
||||
|
||||
colour = nc3.get_colour(session, BAD_STRING)
|
||||
assert colour is None
|
||||
assert colour == ""
|
||||
|
||||
def test_name_available(self):
|
||||
PLAYLIST_NAME = "a name"
|
||||
@ -254,7 +273,7 @@ class TestMMModels(unittest.TestCase):
|
||||
|
||||
with db.Session() as session:
|
||||
if Playlists.name_is_available(session, PLAYLIST_NAME):
|
||||
playlist = Playlists(session, PLAYLIST_NAME)
|
||||
playlist = Playlists(session, PLAYLIST_NAME, template_id=0)
|
||||
assert playlist
|
||||
|
||||
assert Playlists.name_is_available(session, PLAYLIST_NAME) is False
|
||||
@ -266,7 +285,7 @@ class TestMMModels(unittest.TestCase):
|
||||
|
||||
with db.Session() as session:
|
||||
if Playlists.name_is_available(session, PLAYLIST_NAME):
|
||||
playlist = Playlists(session, PLAYLIST_NAME)
|
||||
playlist = Playlists(session=session, name=PLAYLIST_NAME, template_id=0)
|
||||
|
||||
plr = PlaylistRows(session, playlist.id, 1)
|
||||
assert plr
|
||||
@ -279,7 +298,7 @@ class TestMMModels(unittest.TestCase):
|
||||
|
||||
with db.Session() as session:
|
||||
if Playlists.name_is_available(session, PLAYLIST_NAME):
|
||||
playlist = Playlists(session, PLAYLIST_NAME)
|
||||
playlist = Playlists(session=session, name=PLAYLIST_NAME, template_id=0)
|
||||
|
||||
plr = PlaylistRows(session, playlist.id, 1)
|
||||
assert plr
|
||||
|
||||
@ -34,8 +34,8 @@ class TestMMMiscTracks(unittest.TestCase):
|
||||
|
||||
# Create a playlist and model
|
||||
with db.Session() as session:
|
||||
self.playlist = Playlists(session, PLAYLIST_NAME)
|
||||
self.model = playlistmodel.PlaylistModel(self.playlist.id)
|
||||
self.playlist = Playlists(session, PLAYLIST_NAME, template_id=0)
|
||||
self.model = playlistmodel.PlaylistModel(self.playlist.id, is_template=False)
|
||||
|
||||
for row in range(len(self.test_tracks)):
|
||||
track_path = self.test_tracks[row % len(self.test_tracks)]
|
||||
@ -56,7 +56,7 @@ class TestMMMiscTracks(unittest.TestCase):
|
||||
assert max(self.model.playlist_rows.keys()) == 7
|
||||
for row in range(self.model.rowCount()):
|
||||
assert row in self.model.playlist_rows
|
||||
assert self.model.playlist_rows[row].plr_rownum == row
|
||||
assert self.model.playlist_rows[row].row_number == row
|
||||
|
||||
def test_timing_one_track(self):
|
||||
START_ROW = 0
|
||||
@ -66,10 +66,10 @@ class TestMMMiscTracks(unittest.TestCase):
|
||||
self.model.insert_row(proposed_row_number=END_ROW, note="-")
|
||||
|
||||
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
|
||||
)
|
||||
assert qv_value.value() == "start [1 tracks, 4:23 unplayed]"
|
||||
assert qv_value == "start [1 tracks, 4:23 unplayed]"
|
||||
|
||||
|
||||
class TestMMMiscNoPlaylist(unittest.TestCase):
|
||||
@ -93,9 +93,9 @@ class TestMMMiscNoPlaylist(unittest.TestCase):
|
||||
def test_insert_track_new_playlist(self):
|
||||
# insert a track into a new playlist
|
||||
with db.Session() as session:
|
||||
playlist = Playlists(session, self.PLAYLIST_NAME)
|
||||
playlist = Playlists(session, self.PLAYLIST_NAME, template_id=0)
|
||||
# Create a model
|
||||
model = playlistmodel.PlaylistModel(playlist.id)
|
||||
model = playlistmodel.PlaylistModel(playlist.id, is_template=False)
|
||||
# test repr
|
||||
_ = str(model)
|
||||
|
||||
@ -109,7 +109,7 @@ class TestMMMiscNoPlaylist(unittest.TestCase):
|
||||
_ = str(prd)
|
||||
|
||||
assert (
|
||||
model.edit_role(
|
||||
model._edit_role(
|
||||
model.rowCount() - 1, playlistmodel.Col.TITLE.value, prd
|
||||
)
|
||||
== metadata["title"]
|
||||
@ -124,8 +124,8 @@ class TestMMMiscRowMove(unittest.TestCase):
|
||||
db.create_all()
|
||||
|
||||
with db.Session() as session:
|
||||
self.playlist = Playlists(session, self.PLAYLIST_NAME)
|
||||
self.model = playlistmodel.PlaylistModel(self.playlist.id)
|
||||
self.playlist = Playlists(session, self.PLAYLIST_NAME, template_id=0)
|
||||
self.model = playlistmodel.PlaylistModel(self.playlist.id, is_template=False)
|
||||
for row in range(self.ROWS_TO_CREATE):
|
||||
self.model.insert_row(proposed_row_number=row, note=str(row))
|
||||
|
||||
@ -140,7 +140,7 @@ class TestMMMiscRowMove(unittest.TestCase):
|
||||
# Check we have all rows and plr_rownums are correct
|
||||
for row in range(self.model.rowCount()):
|
||||
assert row in self.model.playlist_rows
|
||||
assert self.model.playlist_rows[row].plr_rownum == row
|
||||
assert self.model.playlist_rows[row].row_number == row
|
||||
if row not in [3, 4, 5]:
|
||||
assert self.model.playlist_rows[row].note == str(row)
|
||||
elif row == 3:
|
||||
@ -158,7 +158,7 @@ class TestMMMiscRowMove(unittest.TestCase):
|
||||
# Check we have all rows and plr_rownums are correct
|
||||
for row in range(self.model.rowCount()):
|
||||
assert row in self.model.playlist_rows
|
||||
assert self.model.playlist_rows[row].plr_rownum == row
|
||||
assert self.model.playlist_rows[row].row_number == row
|
||||
if row not in [3, 4]:
|
||||
assert self.model.playlist_rows[row].note == str(row)
|
||||
elif row == 3:
|
||||
@ -174,7 +174,7 @@ class TestMMMiscRowMove(unittest.TestCase):
|
||||
# Check we have all rows and plr_rownums are correct
|
||||
for row in range(self.model.rowCount()):
|
||||
assert row in self.model.playlist_rows
|
||||
assert self.model.playlist_rows[row].plr_rownum == row
|
||||
assert self.model.playlist_rows[row].row_number == row
|
||||
if row not in [2, 3, 4]:
|
||||
assert self.model.playlist_rows[row].note == str(row)
|
||||
elif row == 2:
|
||||
@ -193,7 +193,7 @@ class TestMMMiscRowMove(unittest.TestCase):
|
||||
new_order = []
|
||||
for row in range(self.model.rowCount()):
|
||||
assert row in self.model.playlist_rows
|
||||
assert self.model.playlist_rows[row].plr_rownum == row
|
||||
assert self.model.playlist_rows[row].row_number == row
|
||||
new_order.append(int(self.model.playlist_rows[row].note))
|
||||
assert new_order == [0, 2, 3, 6, 7, 1, 4, 5, 10, 8, 9]
|
||||
|
||||
@ -206,7 +206,7 @@ class TestMMMiscRowMove(unittest.TestCase):
|
||||
new_order = []
|
||||
for row in range(self.model.rowCount()):
|
||||
assert row in self.model.playlist_rows
|
||||
assert self.model.playlist_rows[row].plr_rownum == row
|
||||
assert self.model.playlist_rows[row].row_number == row
|
||||
new_order.append(int(self.model.playlist_rows[row].note))
|
||||
assert new_order == [0, 1, 2, 4, 3, 6, 5, 7, 8, 9, 10]
|
||||
|
||||
@ -219,7 +219,7 @@ class TestMMMiscRowMove(unittest.TestCase):
|
||||
new_order = []
|
||||
for row in range(self.model.rowCount()):
|
||||
assert row in self.model.playlist_rows
|
||||
assert self.model.playlist_rows[row].plr_rownum == row
|
||||
assert self.model.playlist_rows[row].row_number == row
|
||||
new_order.append(int(self.model.playlist_rows[row].note))
|
||||
assert new_order == [0, 1, 2, 4, 7, 3, 5, 6, 8, 9, 10]
|
||||
|
||||
@ -232,7 +232,7 @@ class TestMMMiscRowMove(unittest.TestCase):
|
||||
new_order = []
|
||||
for row in range(self.model.rowCount()):
|
||||
assert row in self.model.playlist_rows
|
||||
assert self.model.playlist_rows[row].plr_rownum == row
|
||||
assert self.model.playlist_rows[row].row_number == row
|
||||
new_order.append(int(self.model.playlist_rows[row].note))
|
||||
assert new_order == [0, 1, 2, 3, 4, 7, 8, 10, 5, 6, 9]
|
||||
|
||||
@ -246,7 +246,7 @@ class TestMMMiscRowMove(unittest.TestCase):
|
||||
new_order = []
|
||||
for row in range(self.model.rowCount()):
|
||||
assert row in self.model.playlist_rows
|
||||
assert self.model.playlist_rows[row].plr_rownum == row
|
||||
assert self.model.playlist_rows[row].row_number == row
|
||||
new_order.append(int(self.model.playlist_rows[row].note))
|
||||
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
|
||||
# handled differently (sets up row span)
|
||||
assert (
|
||||
self.model.edit_role(
|
||||
self.model._edit_role(
|
||||
self.model.rowCount() - 1, playlistmodel.Col.NOTE.value, prd
|
||||
)
|
||||
== note_text
|
||||
@ -280,7 +280,7 @@ class TestMMMiscRowMove(unittest.TestCase):
|
||||
# Test against edit_role because display_role for headers is
|
||||
# handled differently (sets up row span)
|
||||
assert (
|
||||
self.model.edit_role(
|
||||
self.model._edit_role(
|
||||
self.model.rowCount() - 1, playlistmodel.Col.NOTE.value, prd
|
||||
)
|
||||
== note_text
|
||||
@ -318,8 +318,8 @@ class TestMMMiscRowMove(unittest.TestCase):
|
||||
|
||||
model_src = self.model
|
||||
with db.Session() as session:
|
||||
playlist_dst = Playlists(session, destination_playlist)
|
||||
model_dst = playlistmodel.PlaylistModel(playlist_dst.id)
|
||||
playlist_dst = Playlists(session, destination_playlist, template_id=0)
|
||||
model_dst = playlistmodel.PlaylistModel(playlist_dst.id, is_template=False)
|
||||
for row in range(self.ROWS_TO_CREATE):
|
||||
model_dst.insert_row(proposed_row_number=row, note=str(row))
|
||||
|
||||
@ -328,7 +328,7 @@ class TestMMMiscRowMove(unittest.TestCase):
|
||||
|
||||
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 sorted([a.plr_rownum for a in model_src.playlist_rows.values()]) == list(
|
||||
assert sorted([a.row_number for a in model_src.playlist_rows.values()]) == list(
|
||||
range(len(model_src.playlist_rows))
|
||||
)
|
||||
|
||||
@ -339,8 +339,8 @@ class TestMMMiscRowMove(unittest.TestCase):
|
||||
|
||||
model_src = self.model
|
||||
with db.Session() as session:
|
||||
playlist_dst = Playlists(session, destination_playlist)
|
||||
model_dst = playlistmodel.PlaylistModel(playlist_dst.id)
|
||||
playlist_dst = Playlists(session, destination_playlist, template_id=0)
|
||||
model_dst = playlistmodel.PlaylistModel(playlist_dst.id, is_template=False)
|
||||
for row in range(self.ROWS_TO_CREATE):
|
||||
model_dst.insert_row(proposed_row_number=row, note=str(row))
|
||||
|
||||
@ -353,7 +353,7 @@ class TestMMMiscRowMove(unittest.TestCase):
|
||||
index = model_dst.index(
|
||||
row_number, playlistmodel.Col.TITLE.value, QModelIndex()
|
||||
)
|
||||
row_notes.append(model_dst.data(index, Qt.ItemDataRole.EditRole).value())
|
||||
row_notes.append(model_dst.data(index, Qt.ItemDataRole.EditRole))
|
||||
|
||||
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)
|
||||
@ -366,8 +366,8 @@ class TestMMMiscRowMove(unittest.TestCase):
|
||||
|
||||
model_src = self.model
|
||||
with db.Session() as session:
|
||||
playlist_dst = Playlists(session, destination_playlist)
|
||||
model_dst = playlistmodel.PlaylistModel(playlist_dst.id)
|
||||
playlist_dst = Playlists(session, destination_playlist, template_id=0)
|
||||
model_dst = playlistmodel.PlaylistModel(playlist_dst.id, is_template=False)
|
||||
for row in range(self.ROWS_TO_CREATE):
|
||||
model_dst.insert_row(proposed_row_number=row, note=str(row))
|
||||
|
||||
@ -380,16 +380,16 @@ class TestMMMiscRowMove(unittest.TestCase):
|
||||
index = model_dst.index(
|
||||
row_number, playlistmodel.Col.TITLE.value, QModelIndex()
|
||||
)
|
||||
row_notes.append(model_dst.data(index, Qt.ItemDataRole.EditRole).value())
|
||||
row_notes.append(model_dst.data(index, Qt.ItemDataRole.EditRole))
|
||||
|
||||
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 [int(a) for a in row_notes] == [
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
3,
|
||||
4,
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
|
||||
130
tests/test_queries.py
Normal file
130
tests/test_queries.py
Normal file
@ -0,0 +1,130 @@
|
||||
# Standard library imports
|
||||
import datetime as dt
|
||||
import unittest
|
||||
|
||||
# PyQt imports
|
||||
|
||||
|
||||
# Third party imports
|
||||
|
||||
# App imports
|
||||
from app.models import (
|
||||
db,
|
||||
Playdates,
|
||||
Tracks,
|
||||
)
|
||||
from classes import (
|
||||
Filter,
|
||||
)
|
||||
|
||||
|
||||
class MyTestCase(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
"""Runs once before any test in this class"""
|
||||
|
||||
db.create_all()
|
||||
|
||||
with db.Session() as session:
|
||||
# Create some track entries
|
||||
_ = Tracks(**dict(
|
||||
session=session,
|
||||
artist="a",
|
||||
bitrate=0,
|
||||
duration=100,
|
||||
fade_at=0,
|
||||
path="/alpha/bravo/charlie",
|
||||
silence_at=0,
|
||||
start_gap=0,
|
||||
title="abc"
|
||||
))
|
||||
track2 = Tracks(**dict(
|
||||
session=session,
|
||||
artist="a",
|
||||
bitrate=0,
|
||||
duration=100,
|
||||
fade_at=0,
|
||||
path="/xray/yankee/zulu",
|
||||
silence_at=0,
|
||||
start_gap=0,
|
||||
title="xyz"
|
||||
))
|
||||
track2_id = track2.id
|
||||
# Add playdates
|
||||
# Track 2 played just over a year ago
|
||||
just_over_a_year_ago = dt.datetime.now() - dt.timedelta(days=367)
|
||||
_ = Playdates(session, track2_id, when=just_over_a_year_ago)
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
"""Runs once after all tests"""
|
||||
|
||||
db.drop_all()
|
||||
|
||||
def setUp(self):
|
||||
"""Runs before each test"""
|
||||
|
||||
pass
|
||||
|
||||
def tearDown(self):
|
||||
"""Runs after each test"""
|
||||
|
||||
pass
|
||||
|
||||
def test_search_path_1(self):
|
||||
"""Search for unplayed track"""
|
||||
|
||||
filter = Filter(path="alpha", last_played_comparator="never")
|
||||
|
||||
with db.Session() as session:
|
||||
results = Tracks.get_filtered_tracks(session, filter)
|
||||
assert len(results) == 1
|
||||
assert 'alpha' in results[0].path
|
||||
|
||||
def test_search_path_2(self):
|
||||
"""Search for unplayed track that doesn't exist"""
|
||||
|
||||
filter = Filter(path="xray", last_played_comparator="never")
|
||||
|
||||
with db.Session() as session:
|
||||
results = Tracks.get_filtered_tracks(session, filter)
|
||||
assert len(results) == 0
|
||||
|
||||
def test_played_over_a_year_ago(self):
|
||||
"""Search for tracks played over a year ago"""
|
||||
|
||||
filter = Filter(last_played_unit="years", last_played_number=1)
|
||||
|
||||
with db.Session() as session:
|
||||
results = Tracks.get_filtered_tracks(session, filter)
|
||||
assert len(results) == 1
|
||||
assert 'zulu' in results[0].path
|
||||
|
||||
def test_played_over_two_years_ago(self):
|
||||
"""Search for tracks played over 2 years ago"""
|
||||
|
||||
filter = Filter(last_played_unit="years", last_played_number=2)
|
||||
|
||||
with db.Session() as session:
|
||||
results = Tracks.get_filtered_tracks(session, filter)
|
||||
assert len(results) == 0
|
||||
|
||||
def test_never_played(self):
|
||||
"""Search for tracks never played"""
|
||||
|
||||
filter = Filter(last_played_comparator="never")
|
||||
|
||||
with db.Session() as session:
|
||||
results = Tracks.get_filtered_tracks(session, filter)
|
||||
assert len(results) == 1
|
||||
assert 'alpha' in results[0].path
|
||||
|
||||
def test_played_anytime(self):
|
||||
"""Search for tracks played over a year ago"""
|
||||
|
||||
filter = Filter(last_played_comparator="Any time")
|
||||
|
||||
with db.Session() as session:
|
||||
results = Tracks.get_filtered_tracks(session, filter)
|
||||
assert len(results) == 1
|
||||
assert 'zulu' in results[0].path
|
||||
@ -3,19 +3,15 @@ import os
|
||||
import unittest
|
||||
|
||||
# PyQt imports
|
||||
from PyQt6.QtCore import Qt
|
||||
from PyQt6.QtGui import QColor
|
||||
|
||||
# Third party imports
|
||||
import pytest
|
||||
from pytestqt.plugin import QtBot # type: ignore
|
||||
|
||||
# App imports
|
||||
from config import Config
|
||||
from app import playlistmodel, utilities
|
||||
from app.models import (
|
||||
db,
|
||||
NoteColours,
|
||||
Playlists,
|
||||
Tracks,
|
||||
)
|
||||
@ -63,7 +59,6 @@ class MyTestCase(unittest.TestCase):
|
||||
"start_gap": 60,
|
||||
"fade_at": 236263,
|
||||
"silence_at": 260343,
|
||||
"mtime": 371900000,
|
||||
},
|
||||
2: {
|
||||
"path": "testdata/mom.mp3",
|
||||
@ -74,7 +69,6 @@ class MyTestCase(unittest.TestCase):
|
||||
"start_gap": 70,
|
||||
"fade_at": 115000,
|
||||
"silence_at": 118000,
|
||||
"mtime": 1642760000,
|
||||
},
|
||||
}
|
||||
|
||||
@ -82,7 +76,7 @@ class MyTestCase(unittest.TestCase):
|
||||
for track in self.tracks.values():
|
||||
db_track = Tracks(session=session, **track)
|
||||
session.add(db_track)
|
||||
track['id'] = db_track.id
|
||||
track["id"] = db_track.id
|
||||
|
||||
session.commit()
|
||||
|
||||
@ -96,8 +90,8 @@ class MyTestCase(unittest.TestCase):
|
||||
playlist_name = "test_init playlist"
|
||||
|
||||
with db.Session() as session:
|
||||
playlist = Playlists(session, playlist_name)
|
||||
self.widget.create_playlist_tab(playlist)
|
||||
playlist = Playlists(session, playlist_name, template_id=0)
|
||||
self.widget._open_playlist(playlist, is_template=False)
|
||||
with self.qtbot.waitExposed(self.widget):
|
||||
self.widget.show()
|
||||
|
||||
@ -109,8 +103,8 @@ class MyTestCase(unittest.TestCase):
|
||||
playlist_name = "test_save_and_restore playlist"
|
||||
|
||||
with db.Session() as session:
|
||||
playlist = Playlists(session, playlist_name)
|
||||
model = playlistmodel.PlaylistModel(playlist.id)
|
||||
playlist = Playlists(session, playlist_name, template_id=0)
|
||||
model = playlistmodel.PlaylistModel(playlist.id, is_template=False)
|
||||
|
||||
# Add a track with a note
|
||||
model.insert_row(
|
||||
@ -136,15 +130,16 @@ class MyTestCase(unittest.TestCase):
|
||||
|
||||
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:
|
||||
utilities.check_db(session)
|
||||
utilities.update_bitrates(session)
|
||||
|
||||
|
||||
# def test_meta_all_clear(qtbot, session):
|
||||
# # Create playlist
|
||||
# playlist = models.Playlists(session, "my playlist")
|
||||
# playlist = models.Playlists(session, "my playlist", template_id=0)
|
||||
# playlist_tab = playlists.PlaylistTab(None, session, playlist.id)
|
||||
|
||||
# # Add some tracks
|
||||
@ -172,7 +167,8 @@ class MyTestCase(unittest.TestCase):
|
||||
|
||||
# def test_meta(qtbot, session):
|
||||
# # Create playlist
|
||||
# playlist = playlists.Playlists(session, "my playlist")
|
||||
# playlist = playlists.Playlists(session, "my playlist",
|
||||
# template_id=0)
|
||||
# playlist_tab = playlists.PlaylistTab(None, session, playlist.id)
|
||||
|
||||
# # Add some tracks
|
||||
@ -253,7 +249,7 @@ class MyTestCase(unittest.TestCase):
|
||||
|
||||
# def test_clear_next(qtbot, session):
|
||||
# # Create playlist
|
||||
# playlist = models.Playlists(session, "my playlist")
|
||||
# playlist = models.Playlists(session, "my playlist", template_id=0)
|
||||
# playlist_tab = playlists.PlaylistTab(None, session, playlist.id)
|
||||
|
||||
# # Add some tracks
|
||||
@ -279,7 +275,7 @@ class MyTestCase(unittest.TestCase):
|
||||
|
||||
# # Create playlist and playlist_tab
|
||||
# window = musicmuster.Window()
|
||||
# playlist = models.Playlists(session, "test playlist")
|
||||
# playlist = models.Playlists(session, "test playlist", template_id=0)
|
||||
# playlist_tab = playlists.PlaylistTab(window, session, playlist.id)
|
||||
|
||||
# # Add some tracks
|
||||
@ -311,7 +307,7 @@ class MyTestCase(unittest.TestCase):
|
||||
# playlist_name = "test playlist"
|
||||
# # Create testing playlist
|
||||
# window = musicmuster.Window()
|
||||
# playlist = models.Playlists(session, playlist_name)
|
||||
# playlist = models.Playlists(session, playlist_name, template_id=0)
|
||||
# playlist_tab = playlists.PlaylistTab(window, session, playlist.id)
|
||||
# idx = window.tabPlaylist.addTab(playlist_tab, playlist_name)
|
||||
# window.tabPlaylist.setCurrentIndex(idx)
|
||||
|
||||
16
web.py
16
web.py
@ -1,16 +0,0 @@
|
||||
#!/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())
|
||||
Loading…
Reference in New Issue
Block a user