Compare commits

..

1 Commits

Author SHA1 Message Date
Keith Edmunds
dbf71ea321 Protect music player during fade 2022-02-28 22:55:09 +00:00
184 changed files with 22770 additions and 47231 deletions

26
.envrc
View File

@ -1,25 +1 @@
layout uv
export LINE_PROFILE=1
export MAIL_PASSWORD="ewacyay5seu2qske"
export MAIL_PORT=587
export MAIL_SERVER="smtp.fastmail.com"
export MAIL_USERNAME="kae@midnighthax.com"
export MAIL_USE_TLS=True
export PYGAME_HIDE_SUPPORT_PROMPT=1
branch=$(git branch --show-current)
# Always treat running from /home/kae/mm as production
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"
else
export MM_ENV="DEVELOPMENT"
export DATABASE_URL="mysql+mysqldb://dev_musicmuster:dev_musicmuster@localhost/dev_musicmuster"
export PYTHONBREAKPOINT="pudb.set_trace"
fi
layout poetry

13
.flake8
View File

@ -1,13 +0,0 @@
[flake8]
max-line-length = 88
select = C,E,F,W,B,B950
extend-ignore = E203, E501
exclude =
.git
app/ui,
__pycache__,
archive,
migrations,
prof,
docs,
app/icons_rc.py

1
.gitattributes vendored
View File

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

5
.gitignore vendored
View File

@ -2,7 +2,6 @@
*.pyc
*.swp
tags
.venv/
venv/
Session.vim
*.flac
@ -11,7 +10,3 @@ StudioPlaylist.png
*.otl
*.howto
.direnv
tmp/
.coverage
profile_output*
kae.py

8
.idea/.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

12
.idea/dataSources.xml Normal file
View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="musicmuster_dev@localhost" uuid="49514dbe-26ec-4cb2-b457-06666d93ac47">
<driver-ref>mariadb</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.mariadb.jdbc.Driver</jdbc-driver>
<jdbc-url>jdbc:mariadb://localhost:3306/musicmuster_dev</jdbc-url>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
</component>
</project>

View File

@ -0,0 +1,14 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="PyPep8Inspection" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="PyPep8NamingInspection" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="PyUnresolvedReferencesInspection" enabled="true" level="WARNING" enabled_by_default="true">
<option name="ignoredIdentifiers">
<list>
<option value="PyQt5.QtWidgets.customContextMenuRequested.*" />
</list>
</option>
</inspection_tool>
</profile>
</component>

View File

@ -0,0 +1,7 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="PROJECT_PROFILE" value="Default" />
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>

4
.idea/misc.xml Normal file
View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectRootManager" version="2" project-jdk-name="Poetry (musicmuster) (2)" project-jdk-type="Python SDK" />
</project>

8
.idea/modules.xml Normal file
View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/musicmuster.iml" filepath="$PROJECT_DIR$/.idea/musicmuster.iml" />
</modules>
</component>
</project>

12
.idea/musicmuster.iml Normal file
View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
<component name="PyDocumentationSettings">
<option name="format" value="PLAIN" />
<option name="myDocStringFormat" value="Plain" />
</component>
</module>

6
.idea/sqldialects.xml Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="SqlDialectMappings">
<file url="PROJECT" dialect="MariaDB" />
</component>
</project>

6
.idea/vcs.xml Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

View File

@ -1 +1 @@
3.13
musicmuster

View File

@ -1,29 +1,18 @@
# a multi-database configuration.
# A generic, single database configuration.
[alembic]
# this must be configured to point to the Alchemical database instance
# there are two components separated by a colon:
# the left part is the import path to the module containing the database instance
# the right part is the name of the database instance, typically 'db'
alchemical_db = models:db
# path to migration scripts
script_location = migrations
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
prepend_sys_path = app
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python-dateutil library that can be
# installed by adding `alembic[tz]` to the pip requirements
# timezone to use when rendering the date
# within the migration file as well as the filename.
# string value is passed to dateutil.tz.gettz()
# leave blank for localtime
# timezone =
@ -41,26 +30,17 @@ prepend_sys_path = app
# versions/ directory
# sourceless = false
# version location specification; This defaults
# version location specification; this defaults
# to migrations/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "version_path_separator" below.
# version_locations = %(here)s/bar:%(here)s/bat:migrations/versions
# version path separator; As mentioned above, this is the character used to split
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
# Valid values for version_path_separator are:
#
# version_path_separator = :
# version_path_separator = ;
# version_path_separator = space
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
# directories, initial revisions must be specified with --version-path
# version_locations = %(here)s/bar %(here)s/bat migrations/versions
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
sqlalchemy.url = mysql+mysqldb://musicmuster:musicmuster@localhost/musicmuster_dev
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
@ -70,7 +50,7 @@ version_path_separator = os # Use os.pathsep. Default configuration used for ne
# hooks=black
# black.type=console_scripts
# black.entrypoint=black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# black.options=-l 79
# Logging configuration
[loggers]

View File

@ -2,7 +2,7 @@
import os
from pydub import AudioSegment
from pydub import AudioSegment, effects
# DIR = "/home/kae/git/musicmuster/archive"
DIR = "/home/kae/git/musicmuster"

View File

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

View File

@ -1,154 +0,0 @@
# Standard library imports
from __future__ import annotations
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
# 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):
START_GAP = 0
TITLE = auto()
ARTIST = auto()
INTRO = auto()
DURATION = auto()
START_TIME = auto()
END_TIME = auto()
LAST_PLAYED = auto()
BITRATE = auto()
NOTE = auto()
class FileErrors(NamedTuple):
path: str
error: str
@dataclass
class Filter:
version: int = 1
path_type: str = "contains"
path: str = ""
last_played_number: int = 0
last_played_comparator: str = "before"
last_played_unit: str = "years"
duration_type: str = "longer than"
duration_number: int = 0
duration_unit: str = "minutes"
@singleton
@dataclass
class MusicMusterSignals(QObject):
"""
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
"""
begin_reset_model_signal = pyqtSignal(int)
enable_escape_signal = pyqtSignal(bool)
end_reset_model_signal = pyqtSignal(int)
next_track_changed_signal = pyqtSignal()
resize_rows_signal = pyqtSignal(int)
search_songfacts_signal = pyqtSignal(str)
search_wikipedia_signal = pyqtSignal(str)
show_warning_signal = pyqtSignal(str, str)
span_cells_signal = pyqtSignal(int, int, int, int, int)
status_message_signal = pyqtSignal(str, int)
track_ended_signal = pyqtSignal()
def __post_init__(self):
super().__init__()
class PlaylistStyle(QProxyStyle):
def drawPrimitive(self, element, option, painter, widget=None):
"""
Draw a line across the entire row rather than just the column
we're hovering over.
"""
if (
element == QStyle.PrimitiveElement.PE_IndicatorItemViewItemDrop
and not option.rect.isNull()
):
option_new = QStyleOption(option)
option_new.rect.setLeft(0)
if widget:
option_new.rect.setRight(widget.width())
option = option_new
super().drawPrimitive(element, option, painter, widget)
class QueryCol(Enum):
TITLE = 0
ARTIST = auto()
DURATION = auto()
LAST_PLAYED = auto()
BITRATE = auto()
class Tags(NamedTuple):
artist: str = ""
title: str = ""
bitrate: int = 0
duration: int = 0
class TrackInfo(NamedTuple):
track_id: int
row_number: int

View File

@ -1,145 +1,54 @@
# Standard library imports
import datetime as dt
import logging
import os
# PyQt imports
# Third party imports
# App imports
class Config(object):
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
COLOUR_BITRATE_LOW = "#ffcdd2"
COLOUR_BITRATE_MEDIUM = "#ffeb6f"
COLOUR_BITRATE_OK = "#dcedc8"
COLOUR_CURRENT_PLAYLIST = "#7eca8f"
COLOUR_CURRENT_HEADER = "#d4edda"
COLOUR_CURRENT_PLAYLIST = "#28a745"
COLOUR_CURRENT_TAB = "#248f24"
COLOUR_ODD_PLAYLIST = "#f2f2f2"
COLOUR_ENDING_TIMER = "#dc3545"
COLOUR_EVEN_PLAYLIST = "#d9d9d9"
COLOUR_LABEL_TEXT = "#000000"
COLOUR_LONG_START = "#dc3545"
COLOUR_NORMAL_TAB = "#000000"
COLOUR_NEXT_HEADER = "#fff3cd"
COLOUR_NEXT_PLAYLIST = "#ffc107"
COLOUR_NEXT_TAB = "#b38600"
COLOUR_NORMAL_TAB = "#000000"
COLOUR_NOTES_PLAYLIST = "#b8daff"
COLOUR_ODD_PLAYLIST = "#f2f2f2"
COLOUR_QUERYLIST_SELECTED = "#d3ffd3"
COLOUR_PREVIOUS_HEADER = "#f8d7da"
COLOUR_UNREADABLE = "#dc3545"
COLOUR_WARNING_TIMER = "#ffc107"
DBFS_FADE = -12
DBFS_SILENCE = -50
DEFAULT_COLUMN_WIDTH = 200
DISPLAY_SQL = False
DO_NOT_IMPORT = "Do not import"
ENGINE_OPTIONS = dict(pool_pre_ping=True)
# ENGINE_OPTIONS = dict(pool_pre_ping=True, echo=True)
EPOCH = dt.datetime(1970, 1, 1)
ERRORS_FROM = ["noreply@midnighthax.com"]
ERRORS_TO = ["kae@midnighthax.com"]
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"
HEADER_END_TIME = "End"
HEADER_INTRO = "Intro"
HEADER_LAST_PLAYED = "Last played"
HEADER_NOTE = "Notes"
HEADER_START_GAP = "Gap"
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"
ERRORS_TO = ['kae@midnighthax.com']
FADE_STEPS = 20
FADE_TIME = 3000
INFO_TAB_TITLE_LENGTH = 15
INTRO_SECONDS_FORMAT = ".1f"
INTRO_SECONDS_WARNING_MS = 3000
LAST_PLAYED_TODAY_STRING = "Today"
LAST_PLAYED_TOOLTIP_DATE_FORMAT = "%a, %d %b %Y"
LOG_LEVEL_STDERR = logging.INFO
LOG_LEVEL_SYSLOG = logging.DEBUG
LOG_NAME = "musicmuster"
MAIL_PASSWORD = os.environ.get("MAIL_PASSWORD")
MAIL_PORT = int(os.environ.get("MAIL_PORT") or 25)
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
MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD')
MAIL_PORT = int(os.environ.get('MAIL_PORT') or 25)
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
MAX_INFO_TABS = 3
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"
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
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 = 100
VLC_VOLUME_DROP3db = 70
WARNING_MS_BEFORE_FADE = 5500
WARNING_MS_BEFORE_SILENCE = 5500
WEB_ZOOM_FACTOR = 1.2
WIKIPEDIA_ON_NEXT = False
MYSQL_CONNECT = os.environ.get('MYSQL_CONNECT') or "mysql+mysqldb://musicmuster:musicmuster@localhost/musicmuster_dev" # noqa E501
NORMALISE_ON_IMPORT = True
NOTE_COLOURS = {
'track': "#ffff00",
'request': "#7cf000",
'wrap': "#fffacd",
'this month then': "#c256c2",
'story': "#dda0dd",
}
ROOT = os.environ.get('ROOT') or "/home/kae/music"
TESTMODE = True
TIMER_MS = 500
VOLUME_VLC_DEFAULT = 75
# 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)
config = Config

View File

@ -1,31 +0,0 @@
# Standard library imports
# PyQt imports
# Third party imports
from alchemical import Alchemical # type:ignore
# App imports
class DatabaseManager:
"""
Singleton class to ensure we only ever have one db object
"""
__instance = None
def __init__(self, database_url: str, **kwargs: dict) -> None:
if DatabaseManager.__instance is None:
self.db = Alchemical(database_url, **kwargs)
# 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: dict) -> Alchemical:
if DatabaseManager.__instance is None:
DatabaseManager(database_url, **kwargs)
return DatabaseManager.__instance

View File

@ -1,226 +0,0 @@
# Standard library imports
from typing import Optional
from dataclasses import asdict
import datetime as dt
import json
# PyQt imports
# Third party imports
from alchemical import Model # type: ignore
from sqlalchemy import (
Boolean,
DateTime,
ForeignKey,
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
class NoteColoursTable(Model):
__tablename__ = "notecolours"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
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 (
f"<NoteColours(id={self.id}, substring={self.substring}, "
f"colour={self.colour}>"
)
class PlaydatesTable(Model):
__tablename__ = "playdates"
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", ondelete="CASCADE"))
track: Mapped["TracksTable"] = relationship(
"TracksTable",
back_populates="playdates",
)
def __repr__(self) -> str:
return (
f"<Playdates(id={self.id}, track_id={self.track_id} "
f"lastplayed={self.lastplayed}>"
)
class PlaylistsTable(Model):
"""
Manage playlists
"""
__tablename__ = "playlists"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(32), unique=True)
last_used: Mapped[Optional[dt.datetime]] = mapped_column(DateTime, default=None)
tab: Mapped[Optional[int]] = mapped_column(default=None)
open: Mapped[bool] = mapped_column(default=False)
is_template: Mapped[bool] = mapped_column(default=False)
rows: Mapped[list["PlaylistRowsTable"]] = relationship(
"PlaylistRowsTable",
back_populates="playlist",
cascade="all, delete-orphan",
order_by="PlaylistRowsTable.row_number",
)
favourite: Mapped[bool] = mapped_column(
Boolean, nullable=False, index=False, default=False
)
def __repr__(self) -> str:
return (
f"<Playlists(id={self.id}, name={self.name}, "
f"is_templatee={self.is_template}, open={self.open}>"
)
class PlaylistRowsTable(Model):
__tablename__ = "playlist_rows"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
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", ondelete="CASCADE"), index=True
)
playlist: Mapped[PlaylistsTable] = relationship(back_populates="rows")
track_id: Mapped[Optional[int]] = mapped_column(
ForeignKey("tracks.id", ondelete="CASCADE")
)
track: Mapped["TracksTable"] = relationship(
"TracksTable",
back_populates="playlistrows",
)
played: Mapped[bool] = mapped_column(
Boolean, nullable=False, index=False, default=False
)
def __repr__(self) -> str:
return (
f"<PlaylistRows(id={self.id}, playlist_id={self.playlist_id}, "
f"track_id={self.track_id}, "
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"""
__tablename__ = "settings"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(64), unique=True)
f_datetime: Mapped[Optional[dt.datetime]] = mapped_column(default=None)
f_int: Mapped[Optional[int]] = mapped_column(default=None)
f_string: Mapped[Optional[str]] = mapped_column(String(128), default=None)
def __repr__(self) -> str:
return (
f"<Settings(id={self.id}, name={self.name}, "
f"f_datetime={self.f_datetime}, f_int={self.f_int}, f_string={self.f_string}>"
)
class TracksTable(Model):
__tablename__ = "tracks"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
artist: Mapped[str] = mapped_column(String(256), index=True)
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)
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)
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(
"PlaydatesTable",
back_populates="track",
cascade="all, delete-orphan",
lazy="joined",
)
def __repr__(self) -> str:
return (
f"<Track(id={self.id}, title={self.title}, "
f"artist={self.artist}, path={self.path}>"
)

View File

@ -1,231 +0,0 @@
# Standard library imports
from typing import Optional
# PyQt imports
from PyQt6.QtCore import QEvent, Qt
from PyQt6.QtGui import QKeyEvent
from PyQt6.QtWidgets import (
QDialog,
QListWidgetItem,
QMainWindow,
)
# Third party imports
from sqlalchemy.orm.session import Session
# App imports
from classes import MusicMusterSignals
from helpers import (
ask_yes_no,
get_relative_date,
ms_to_mmss,
)
from log import log
from models import Settings, Tracks
from playlistmodel import PlaylistModel
from ui import dlg_TrackSelect_ui
class TrackSelectDialog(QDialog):
"""Select track from database"""
def __init__(
self,
parent: QMainWindow,
session: Session,
new_row_number: int,
base_model: PlaylistModel,
add_to_header: Optional[bool] = False,
*args: Qt.WindowType,
**kwargs: Qt.WindowType,
) -> None:
"""
Subclassed QDialog to manage track selection
"""
super().__init__(parent, *args, **kwargs)
self.session = session
self.new_row_number = new_row_number
self.base_model = base_model
self.add_to_header = add_to_header
self.ui = dlg_TrackSelect_ui.Ui_Dialog()
self.ui.setupUi(self)
self.ui.btnAdd.clicked.connect(self.add_selected)
self.ui.btnAddClose.clicked.connect(self.add_selected_and_close)
self.ui.btnClose.clicked.connect(self.close)
self.ui.matchList.itemDoubleClicked.connect(self.add_selected)
self.ui.matchList.itemSelectionChanged.connect(self.selection_changed)
self.ui.radioTitle.toggled.connect(self.title_artist_toggle)
self.ui.searchString.textEdited.connect(self.chars_typed)
self.track: Optional[Tracks] = None
self.signals = MusicMusterSignals()
record = Settings.get_setting(self.session, "dbdialog_width")
width = record.f_int or 800
record = Settings.get_setting(self.session, "dbdialog_height")
height = record.f_int or 600
self.resize(width, height)
if add_to_header:
self.ui.lblNote.setVisible(False)
self.ui.txtNote.setVisible(False)
def add_selected(self) -> None:
"""Handle Add button"""
track = None
if self.ui.matchList.selectedItems():
item = self.ui.matchList.currentItem()
if item:
track = item.data(Qt.ItemDataRole.UserRole)
note = self.ui.txtNote.text()
if not (track or note):
return
track_id = None
if track:
track_id = track.id
if note and not track_id:
self.base_model.insert_row(self.new_row_number, track_id, note)
self.ui.txtNote.clear()
self.new_row_number += 1
return
self.ui.txtNote.clear()
self.select_searchtext()
if track_id is None:
log.error("track_id is None and should not be")
return
# Check whether track is already in playlist
move_existing = False
existing_prd = self.base_model.is_track_in_playlist(track_id)
if existing_prd is not None:
if ask_yes_no(
"Duplicate row",
"Track already in playlist. " "Move to new location?",
default_yes=True,
):
move_existing = True
if self.add_to_header:
if move_existing and existing_prd: # "and existing_prd" for mypy's benefit
self.base_model.move_track_to_header(
self.new_row_number, existing_prd, note
)
else:
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.base_model.move_track_add_note(
self.new_row_number, existing_prd, note
)
else:
self.base_model.insert_row(self.new_row_number, track_id, note)
self.new_row_number += 1
def add_selected_and_close(self) -> None:
"""Handle Add and Close button"""
self.add_selected()
self.accept()
def chars_typed(self, s: str) -> None:
"""Handle text typed in search box"""
self.ui.matchList.clear()
if len(s) > 0:
if s.startswith("a/") and len(s) > 2:
matches = Tracks.search_artists(self.session, "%" + s[2:])
elif self.ui.radioTitle.isChecked():
matches = Tracks.search_titles(self.session, "%" + s)
else:
matches = Tracks.search_artists(self.session, "%" + s)
if matches:
for track in matches:
last_played = None
last_playdate = max(
track.playdates, key=lambda p: p.lastplayed, default=None
)
if last_playdate:
last_played = last_playdate.lastplayed
t = QListWidgetItem()
track_text = (
f"{track.title} - {track.artist} "
f"[{ms_to_mmss(track.duration)}] "
f"({get_relative_date(last_played)})"
)
t.setText(track_text)
t.setData(Qt.ItemDataRole.UserRole, track)
self.ui.matchList.addItem(t)
def closeEvent(self, event: Optional[QEvent]) -> None:
"""
Override close and save dialog coordinates
"""
if not event:
return
record = Settings.get_setting(self.session, "dbdialog_height")
record.f_int = self.height()
record = Settings.get_setting(self.session, "dbdialog_width")
record.f_int = self.width()
self.session.commit()
event.accept()
def keyPressEvent(self, event: QKeyEvent | None) -> None:
"""
Clear selection on ESC if there is one
"""
if event and event.key() == Qt.Key.Key_Escape:
if self.ui.matchList.selectedItems():
self.ui.matchList.clearSelection()
return
super(TrackSelectDialog, self).keyPressEvent(event)
def select_searchtext(self) -> None:
"""Select the searchbox"""
self.ui.searchString.selectAll()
self.ui.searchString.setFocus()
def selection_changed(self) -> None:
"""Display selected track path in dialog box"""
if not self.ui.matchList.selectedItems():
return
item = self.ui.matchList.currentItem()
track = item.data(Qt.ItemDataRole.UserRole)
last_playdate = max(track.playdates, key=lambda p: p.lastplayed, default=None)
if last_playdate:
last_played = last_playdate.lastplayed
else:
last_played = None
path_text = f"{track.path} ({get_relative_date(last_played)})"
self.ui.dbPath.setText(path_text)
def title_artist_toggle(self) -> None:
"""
Handle switching between searching for artists and searching for
titles
"""
# Logic is handled already in chars_typed(), so just call that.
self.chars_typed(self.ui.searchString.text())

View File

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

View File

@ -1,187 +1,11 @@
# Standard library imports
import datetime as dt
from email.message import EmailMessage
from typing import Optional
import os
import re
import shutil
import smtplib
import ssl
import tempfile
import psutil
# PyQt imports
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, TinyTagException # type: ignore
# App imports
from classes import AudioMetadata, ApplicationError, Tags
from config import Config
from log import log
from models import Tracks
start_time_re = re.compile(r"@\d\d:\d\d")
from datetime import datetime
from PyQt5.QtWidgets import QMessageBox
def ask_yes_no(
title: str,
question: str,
default_yes: bool = False,
parent: Optional[QMainWindow] = None,
) -> bool:
"""Ask question; return True for yes, False for no"""
dlg = QMessageBox(parent)
dlg.setWindowTitle(title)
dlg.setText(question)
dlg.setStandardButtons(
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
dlg.setIcon(QMessageBox.Icon.Question)
if default_yes:
dlg.setDefaultButton(QMessageBox.StandardButton.Yes)
button = dlg.exec()
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,
chunk_size: int = Config.AUDIO_SEGMENT_CHUNK_SIZE,
) -> int:
"""
Returns the millisecond/index of the point where the volume drops below
the maximum and doesn't get louder again.
audio_segment - the sdlg_search_database_uiegment to find silence in
fade_threshold - the upper bound for how quiet is silent in dFBS
chunk_size - chunk size for interating over the segment in ms
"""
assert chunk_size > 0 # to avoid infinite loop
segment_length: int = audio_segment.duration_seconds * 1000 # ms
trim_ms = segment_length - chunk_size
max_vol = audio_segment.dBFS
if fade_threshold == 0:
fade_threshold = max_vol
while (
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
# the chunk_size, but for chunk_size = 10ms, this may be ignored)
return int(trim_ms)
def file_is_unreadable(path: Optional[str]) -> bool:
"""
Returns True if passed path is readable, else False
"""
if not path:
return True
return not os.access(path, os.R_OK)
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)
elif path.endswith(".flac"):
return AudioSegment.from_file(path, "flac") # type: ignore
except AttributeError:
return None
return None
def get_embedded_time(text: str) -> Optional[dt.datetime]:
"""Return datetime specified as @hh:mm in text"""
try:
match = start_time_re.search(text)
except TypeError:
return None
if not match:
return None
try:
return dt.datetime.strptime(match.group(0)[1:], Config.NOTE_TIME_FORMAT)
except ValueError:
return None
def get_all_track_metadata(filepath: str) -> dict[str, str | int | float]:
"""Return all track metadata"""
return (
get_audio_metadata(filepath)._asdict()
| get_tags(filepath)._asdict()
| dict(path=filepath)
)
def get_audio_metadata(filepath: str) -> AudioMetadata:
"""Return audio metadata"""
# Set start_gap, fade_at and silence_at
audio = get_audio_segment(filepath)
if not audio:
return AudioMetadata()
else:
return AudioMetadata(
start_gap=leading_silence(audio),
fade_at=int(
round(fade_point(audio) / 1000, Config.MILLISECOND_SIGFIGS) * 1000
),
silence_at=int(
round(trailing_silence(audio) / 1000, Config.MILLISECOND_SIGFIGS) * 1000
),
)
def get_name(prompt: str, default: str = "") -> str | None:
"""Get a name from the user"""
dlg = QInputDialog()
dlg.setInputMode(QInputDialog.InputMode.TextInput)
dlg.setLabelText(prompt)
while True:
if default:
dlg.setTextValue(default)
dlg.resize(500, 100)
ok = dlg.exec()
if ok:
return dlg.textValue()
return None
def get_relative_date(
past_date: Optional[dt.datetime], reference_date: Optional[dt.datetime] = None
) -> str:
def get_relative_date(past_date, reference_date=None):
"""
Return how long before reference_date past_date is as string.
@ -192,24 +16,19 @@ def get_relative_date(
@return: string
"""
if not past_date or past_date == Config.EPOCH:
if not past_date:
return "Never"
if not reference_date:
reference_date = dt.datetime.now()
reference_date = datetime.now()
# Check parameters
if past_date > reference_date:
return "get_relative_date() past_date is after relative_date"
days: int
days_str: str
weeks: int
weeks_str: str
weeks, days = divmod((reference_date.date() - past_date.date()).days, 7)
if weeks == days == 0:
# Same day so return time instead
return Config.LAST_PLAYED_TODAY_STRING + " " + past_date.strftime("%H:%M")
# Played today, so return time instead
return past_date.strftime("%H:%M")
if weeks == 1:
weeks_str = "week"
else:
@ -218,80 +37,59 @@ def get_relative_date(
days_str = "day"
else:
days_str = "days"
return f"{weeks} {weeks_str}, {days} {days_str}"
return f"{weeks} {weeks_str}, {days} {days_str} ago"
def get_tags(path: str) -> Tags:
def open_in_audacity(path):
"""
Return a dictionary of title, artist, bitrate and duration-in-milliseconds.
Open passed file in Audacity
Return True if apparently opened successfully, else False
"""
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 if audacity not running
if "audacity" not in [i.name() for i in psutil.process_iter()]:
return False
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}")
to_pipe = '/tmp/audacity_script_pipe.to.' + str(os.getuid())
from_pipe = '/tmp/audacity_script_pipe.from.' + str(os.getuid())
EOL = '\n'
return Tags(
title=tag.title,
artist=tag.artist,
bitrate=round(tag.bitrate),
duration=int(round(tag.duration, Config.MILLISECOND_SIGFIGS) * 1000),
)
def send_command(command):
"""Send a single command."""
to_audacity.write(command + EOL)
to_audacity.flush()
def get_response():
"""Return the command response."""
result = ''
line = ''
while True:
result += line
line = from_audacity.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()
return response
with open(to_pipe, 'w') as to_audacity, open(
from_pipe, 'rt') as from_audacity:
do_command(f'Import2: Filename="{path}"')
def leading_silence(
audio_segment: AudioSegment,
silence_threshold: int = Config.DBFS_SILENCE,
chunk_size: int = Config.AUDIO_SEGMENT_CHUNK_SIZE,
) -> int:
"""
Returns the millisecond/index that the leading silence ends.
audio_segment - the segment to find silence in
silence_threshold - the upper bound for how quiet is silent in dFBS
chunk_size - chunk size for interating over the segment in ms
def show_warning(title, msg):
"Display a warning to user"
https://github.com/jiaaro/pydub/blob/master/pydub/silence.py
"""
trim_ms: int = 0 # ms
assert chunk_size > 0 # to avoid infinite loop
while audio_segment[
trim_ms : trim_ms + chunk_size
].dBFS < silence_threshold and trim_ms < len( # noqa W504
audio_segment
):
trim_ms += chunk_size
# if there is no end it should return the length of the segment
return min(trim_ms, len(audio_segment))
QMessageBox.warning(None, title, msg, buttons=QMessageBox.Cancel)
def ms_to_mmss(
ms: Optional[int],
decimals: int = 0,
negative: bool = False,
none: Optional[str] = None,
) -> str:
"""Convert milliseconds to mm:ss"""
minutes: int
remainder: int
seconds: float
def ms_to_mmss(ms, decimals=0, negative=False):
if not ms:
if none:
return none
else:
return "-"
sign = ""
if ms < 0:
@ -309,149 +107,3 @@ def ms_to_mmss(
seconds = 59.0
return f"{sign}{minutes:.0f}:{seconds:02.{decimals}f}"
def normalise_track(path: str) -> None:
"""Normalise track"""
# Check type
ftype = os.path.splitext(path)[1][1:]
if ftype not in ["mp3", "flac"]:
log.error(
f"helpers.normalise_track({path}): " f"File type {ftype} not implemented"
)
bitrate = mediainfo(path)["bit_rate"]
audio = get_audio_segment(path)
if not audio:
return
# Get current file gid, uid and permissions
stats = os.stat(path)
try:
# Copy original file
_, temp_path = tempfile.mkstemp()
shutil.copyfile(path, temp_path)
except Exception as err:
log.debug(f"helpers.normalise_track({path}): err1: {repr(err)}")
return
# Overwrite original file with normalised output
normalised = effects.normalize(audio)
try:
normalised.export(path, format=os.path.splitext(path)[1][1:], bitrate=bitrate)
# Fix up permssions and ownership
os.chown(path, stats.st_uid, stats.st_gid)
os.chmod(path, stats.st_mode)
# Copy tags
tag_handler: type[FLAC | MP3]
if ftype == "flac":
tag_handler = FLAC
elif ftype == "mp3":
tag_handler = MP3
else:
return
src = tag_handler(temp_path)
dst = tag_handler(path)
for tag in src:
dst[tag] = src[tag]
dst.save()
except Exception as err:
log.debug(f"helpers.normalise_track({path}): err2: {repr(err)}")
# Restore original file
shutil.copyfile(path, temp_path)
finally:
if os.path.exists(temp_path):
os.remove(temp_path)
def remove_substring_case_insensitive(parent_string: str, substring: str) -> str:
"""
Remove all instances of substring from parent string, case insensitively
"""
# Convert both strings to lowercase for case-insensitive comparison
lower_parent = parent_string.lower()
lower_substring = substring.lower()
# Initialize the result string
result = parent_string
# Continue removing the substring until it's no longer found
while lower_substring in lower_parent:
# Find the index of the substring
index = lower_parent.find(lower_substring)
# Remove the substring
result = result[:index] + result[index + len(substring) :]
# Update the lowercase versions
lower_parent = result.lower()
return result
def send_mail(to_addr: str, from_addr: str, subj: str, body: str) -> None:
# From https://docs.python.org/3/library/email.examples.html
# Create a text/plain message
msg = EmailMessage()
msg.set_content(body)
msg["Subject"] = subj
msg["From"] = from_addr
msg["To"] = to_addr
# Send the message via SMTP server.
context = ssl.create_default_context()
try:
s = smtplib.SMTP(host=Config.MAIL_SERVER, port=Config.MAIL_PORT)
if Config.MAIL_USE_TLS:
s.starttls(context=context)
if Config.MAIL_USERNAME and Config.MAIL_PASSWORD:
s.login(Config.MAIL_USERNAME, Config.MAIL_PASSWORD)
s.send_message(msg)
except Exception as e:
print(e)
finally:
s.quit()
def set_track_metadata(track: Tracks) -> None:
"""Set/update track metadata in database"""
audio_metadata = get_audio_metadata(track.path)
tags = get_tags(track.path)
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(title: str, msg: str, parent: Optional[QWidget] = None) -> None:
"""Display a message to user"""
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:
"""Display a warning to user"""
QMessageBox.warning(parent, title, msg, buttons=QMessageBox.StandardButton.Cancel)
def trailing_silence(
audio_segment: AudioSegment,
silence_threshold: int = -50,
chunk_size: int = Config.AUDIO_SEGMENT_CHUNK_SIZE,
) -> int:
"""Return fade point from start in milliseconds"""
return fade_point(audio_segment, silence_threshold, chunk_size)

1
app/icons_rc.py Symbolic link
View File

@ -0,0 +1 @@
ui/icons_rc.py

View File

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

View File

@ -1,138 +1,108 @@
#!/usr/bin/env python3
# Standard library imports
from collections import defaultdict
from functools import wraps
#!/usr/bin/python3
import logging
import logging.config
import logging.handlers
import os
import sys
import traceback
import yaml
# PyQt imports
from PyQt6.QtWidgets import QApplication, QMessageBox
# Third party imports
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"""
"Add leveltag"
def filter(self, record: logging.LogRecord) -> bool:
def filter(self, record):
# Extract the first character of the level name
record.leveltag = record.levelname[0]
# We never actually filter messages out, just add an extra field
# to the LogRecord
# We never actually filter messages out, just abuse filtering to add an
# extra field to the LogRecord
return True
# 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)
# stderr
stderr = logging.StreamHandler()
stderr.setLevel(Config.LOG_LEVEL_STDERR)
# syslog
syslog = logging.handlers.SysLogHandler(address='/dev/log')
syslog.setLevel(Config.LOG_LEVEL_SYSLOG)
# Filter
filter = LevelTagFilter()
syslog.addFilter(filter)
stderr.addFilter(filter)
# create formatter and add it to the handlers
stderr_fmt = logging.Formatter('[%(asctime)s] %(leveltag)s: %(message)s',
datefmt='%H:%M:%S')
syslog_fmt = logging.Formatter('[%(name)s] %(leveltag)s: %(message)s')
stderr.setFormatter(stderr_fmt)
syslog.setFormatter(syslog_fmt)
# add the handlers to the log
log.addHandler(stderr)
log.addHandler(syslog)
def handle_exception(exc_type, exc_value, exc_traceback):
error = str(exc_value)
if issubclass(exc_type, ApplicationError):
log.error(error)
def log_uncaught_exceptions(ex_cls, ex, tb):
print("\033[1;31;47m")
logging.critical(''.join(traceback.format_tb(tb)))
print("\033[1;37;40m")
logging.critical('{0}: {1}'.format(ex_cls, ex))
sys.excepthook = log_uncaught_exceptions
def DEBUG(msg, force_stderr=False):
"""
Outupt a log message at level DEBUG. If force_stderr is True,
output this message to stderr regardless of default stderr level
setting.
"""
if force_stderr:
old_level = stderr.level
stderr.setLevel(logging.DEBUG)
log.debug(msg)
stderr.setLevel(old_level)
else:
# Handle unexpected errors (log and display)
error_msg = "".join(traceback.format_exception(exc_type, exc_value, exc_traceback))
log.debug(msg)
print(stackprinter.format(exc_value, suppressed_paths=['/.venv'], style='darkbg'))
msg = stackprinter.format(exc_value)
def EXCEPTION(msg):
log.exception(msg, exc_info=True, stack_info=True)
def ERROR(msg):
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)
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 INFO(msg):
log.info(msg)
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
if __name__ == "__main__":
DEBUG("hi debug")
ERROR("hi error")
INFO("hi info")
EXCEPTION("hi exception")
def f():
return g()
sys.excepthook = handle_exception
def g():
return h()
def h():
return i()
def i():
1 / 0
f()

View File

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

View File

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

View File

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

666
app/model.py Normal file
View File

@ -0,0 +1,666 @@
#!/usr/bin/python3
import os.path
import re
import sqlalchemy
from datetime import datetime
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy import (
Boolean,
Column,
DateTime,
Float,
ForeignKey,
Integer,
String,
func
)
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm.exc import MultipleResultsFound, NoResultFound
from sqlalchemy.orm import relationship, sessionmaker
from config import Config
from log import DEBUG, ERROR
# Create session at the global level as per
# https://docs.sqlalchemy.org/en/13/orm/session_basics.html
# Set up database connection
engine = sqlalchemy.create_engine(f"{Config.MYSQL_CONNECT}?charset=utf8",
encoding='utf-8',
echo=Config.DISPLAY_SQL,
pool_pre_ping=True)
Base = declarative_base()
Base.metadata.create_all(engine)
# Create a Session factory
Session = sessionmaker(bind=engine)
# Database classes
class NoteColours(Base):
__tablename__ = 'notecolours'
id = Column(Integer, primary_key=True, autoincrement=True)
substring = Column(String(256), index=False)
hexcolour = Column(String(6), index=False)
enabled = Column(Boolean, default=True, index=True)
is_regex = Column(Boolean, default=False, index=False)
is_casesensitive = Column(Boolean, default=False, index=False)
order = Column(Integer, index=True)
def __repr__(self):
return (
f"<NoteColour(id={self.id}, substring={self.substring}, colour={self.hexcolour}>"
)
@staticmethod
def get_colour(session, text):
"""
Parse text and return colour string if match, else None
Currently ignore is_regex and is_casesensitive
"""
for rec in (
session.query(NoteColours)
.filter(NoteColours.enabled == True)
.order_by(NoteColours.order)
.all()
):
if rec.is_regex:
if rec.is_casesensitive:
p = re.compile(rec.substring)
else:
p = re.compile(rec.substring, re.IGNORECASE)
if p.match(text):
return '#' + rec.hexcolour
else:
if rec.is_casesensitive:
if rec.substring in text:
return '#' + rec.hexcolour
else:
if rec.substring.lower() in text.lower():
return '#' + rec.hexcolour
return None
class Notes(Base):
__tablename__ = 'notes'
id = Column(Integer, primary_key=True, autoincrement=True)
playlist_id = Column(Integer, ForeignKey('playlists.id'))
playlist = relationship("Playlists", back_populates="notes")
row = Column(Integer, nullable=False)
note = Column(String(256), index=False)
def __repr__(self):
return (
f"<Note(id={self.id}, row={self.row}, note={self.note}>"
)
@staticmethod
def add_note(session, playlist_id, row, text):
"Add note"
DEBUG(f"add_note(playlist_id={playlist_id}, row={row}, text={text})")
note = Notes()
note.playlist_id = playlist_id
note.row = row
note.note = text
session.add(note)
session.commit()
return note
@staticmethod
def delete_note(session, id):
"Delete note"
DEBUG(f"delete_note(id={id}")
session.query(Notes).filter(Notes.id == id).delete()
session.commit()
@classmethod
def update_note(cls, session, id, row, text=None):
"""
Update note details. If text=None, don't change text.
"""
DEBUG(f"Notes.update_note(id={id}, row={row}, text={text})")
note = session.query(cls).filter(cls.id == id).one()
note.row = row
if text:
note.note = text
session.commit()
class Playdates(Base):
__tablename__ = 'playdates'
id = Column(Integer, primary_key=True, autoincrement=True)
lastplayed = Column(DateTime, index=True, default=None)
track_id = Column(Integer, ForeignKey('tracks.id'))
tracks = relationship("Tracks", back_populates="playdates")
@staticmethod
def add_playdate(session, track):
"Record that track was played"
DEBUG(f"add_playdate(track={track})")
pd = Playdates()
pd.lastplayed = datetime.now()
pd.track_id = track.id
session.add(pd)
track.update_lastplayed(session, track.id)
session.commit()
@staticmethod
def last_played(session, track_id):
"Return datetime track last played or None"
last_played = session.query(Playdates.lastplayed).filter(
(Playdates.track_id == track_id)
).order_by(Playdates.lastplayed.desc()).first()
if last_played:
return last_played[0]
else:
return None
@staticmethod
def remove_track(session, track_id):
"""
Remove all records of track_id
"""
session.query(Playdates).filter(
Playdates.track_id == track_id,
).delete()
session.commit()
class Playlists(Base):
"""
Usage:
pl = session.query(Playlists).filter(Playlists.id == 1).one()
pl
<Playlist(id=1, name=Default>
pl.tracks
[<__main__.PlaylistTracks at 0x7fcd20181c18>,
<__main__.PlaylistTracks at 0x7fcd20181c88>,
<__main__.PlaylistTracks at 0x7fcd20181be0>,
<__main__.PlaylistTracks at 0x7fcd20181c50>]
[a.tracks for a in pl.tracks]
[<Track(id=3992, title=Yesterday Man, artist=Various, path=/h[...]
<Track(id=2238, title=These Boots Are Made For Walkin', arti[...]
<Track(id=3837, title=Babe, artist=Various, path=/home/kae/m[...]
<Track(id=2332, title=Such Great Heights - Remastered, artis[...]]
glue = PlaylistTracks(row=5)
tr = session.query(Tracks).filter(Tracks.id == 676).one()
tr
<Track(id=676, title=Seven Nation Army, artist=White Stripes,
path=/home/kae/music/White Stripes/Elephant/01. Seven Nation Army.flac>
glue.track_id = tr.id
pl.tracks.append(glue)
session.commit()
[a.tracks for a in pl.tracks]
[<Track(id=3992, title=Yesterday Man, artist=Various, path=/h[...]
<Track(id=2238, title=These Boots Are Made For Walkin', arti[...]
<Track(id=3837, title=Babe, artist=Various, path=/home/kae/m[...]
<Track(id=2332, title=Such Great Heights - Remastered, artis[...]
<Track(id=676, title=Seven Nation Army, artist=White Stripes[...]]
"""
__tablename__ = "playlists"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(32), nullable=False, unique=True)
last_used = Column(DateTime, default=None, nullable=True)
loaded = Column(Boolean, default=True)
notes = relationship("Notes",
order_by="Notes.row",
back_populates="playlist")
tracks = relationship("PlaylistTracks",
order_by="PlaylistTracks.row",
back_populates="playlists")
def __repr__(self):
return (f"<Playlists(id={self.id}, name={self.name}>")
def add_track(self, session, track, row=None):
"""
Add track to playlist at given row.
If row=None, add to end of playlist
"""
if not row:
row = PlaylistTracks.new_row(session, self.id)
DEBUG(f"Playlists:add_track({session=}, {track=}, {row=})")
glue = PlaylistTracks(row=row)
glue.track_id = track.id
self.tracks.append(glue)
session.commit()
def close(self, session):
"Record playlist as no longer loaded"
self.loaded = False
session.add(self)
session.commit()
@staticmethod
def get_all_closed_playlists(session):
"Returns a list of all playlists not currently open"
return (
session.query(Playlists)
.filter(
(Playlists.loaded == False) | # noqa E712
(Playlists.loaded == None)
)
.order_by(Playlists.last_used.desc())
).all()
@staticmethod
def get_all_playlists(session):
"Returns a list of all playlists"
return session.query(Playlists).all()
@staticmethod
def get_last_used(session):
"""
Return a list of playlists marked "loaded", ordered by loaded date.
"""
return (
session.query(Playlists)
.filter(Playlists.loaded == True) # noqa E712
.order_by(Playlists.last_used.desc())
).all()
def get_notes(self):
return [a.note for a in self.notes]
@staticmethod
def get_playlist(session, playlist_id):
return (
session.query(Playlists)
.filter(
Playlists.id == playlist_id # noqa E712
)
).one()
def get_tracks(self):
return [a.tracks for a in self.tracks]
@staticmethod
def new(session, name):
DEBUG(f"Playlists.new(name={name})")
playlist = Playlists()
playlist.name = name
session.add(playlist)
session.commit()
return playlist
def open(self, session):
"Mark playlist as loaded and used now"
self.loaded = True
self.last_used = datetime.now()
session.add(self)
session.commit()
class PlaylistTracks(Base):
__tablename__ = 'playlisttracks'
id = Column(Integer, primary_key=True, autoincrement=True)
playlist_id = Column(Integer, ForeignKey('playlists.id'), primary_key=True)
track_id = Column(Integer, ForeignKey('tracks.id'), primary_key=True)
row = Column(Integer, nullable=False)
tracks = relationship("Tracks", back_populates="playlists")
playlists = relationship("Playlists", back_populates="tracks")
@staticmethod
def add_track(session, playlist_id, track_id, row):
DEBUG(
f"PlaylistTracks.add_track(playlist_id={playlist_id}, "
f"track_id={track_id}, row={row})"
)
plt = PlaylistTracks()
plt.playlist_id = playlist_id,
plt.track_id = track_id,
plt.row = row
session.add(plt)
session.commit()
@staticmethod
def get_track_playlists(session, track_id):
"Return all PlaylistTracks objects with this track_id"
return session.query(PlaylistTracks).filter(
PlaylistTracks.track_id == track_id).all()
@staticmethod
def move_track(session, from_playlist_id, row, to_playlist_id):
DEBUG(
"PlaylistTracks.move_tracks(from_playlist_id="
f"{from_playlist_id}, row={row}, "
f"to_playlist_id={to_playlist_id})"
)
max_row = session.query(func.max(PlaylistTracks.row)).filter(
PlaylistTracks.playlist_id == to_playlist_id).scalar()
if max_row is None:
# Destination playlist is empty; use row 0
new_row = 0
else:
# Destination playlist has tracks; add to end
new_row = max_row + 1
try:
record = session.query(PlaylistTracks).filter(
PlaylistTracks.playlist_id == from_playlist_id,
PlaylistTracks.row == row).one()
except NoResultFound:
# Issue #38?
ERROR(
f"No rows matched in query: "
f"PlaylistTracks.playlist_id == {from_playlist_id}, "
f"PlaylistTracks.row == {row}"
)
return
record.playlist_id = to_playlist_id
record.row = new_row
session.commit()
@staticmethod
def new_row(session, playlist_id):
"Return row number > largest existing row number"
last_row = session.query(func.max(PlaylistTracks.row)).one()[0]
return last_row + 1
@staticmethod
def remove_all_tracks(session, playlist_id):
"""
Remove all tracks from passed playlist_id
"""
session.query(PlaylistTracks).filter(
PlaylistTracks.playlist_id == playlist_id,
).delete()
session.commit()
@staticmethod
def remove_track(session, playlist_id, row):
DEBUG(
f"PlaylistTracks.remove_track(playlist_id={playlist_id}, "
f"row={row})"
)
session.query(PlaylistTracks).filter(
PlaylistTracks.playlist_id == playlist_id,
PlaylistTracks.row == row
).delete()
session.commit()
@staticmethod
def update_row_track(session, playlist_id, row, track_id):
DEBUG(
f"PlaylistTracks.update_track_row(playlist_id={playlist_id}, "
f"row={row}, track_id={track_id})"
)
try:
plt = session.query(PlaylistTracks).filter(
PlaylistTracks.playlist_id == playlist_id,
PlaylistTracks.row == row
).one()
except MultipleResultsFound:
ERROR(
f"Multiple rows matched in query: "
f"PlaylistTracks.playlist_id == {playlist_id}, "
f"PlaylistTracks.row == {row}"
)
return
except NoResultFound:
ERROR(
f"No rows matched in query: "
f"PlaylistTracks.playlist_id == {playlist_id}, "
f"PlaylistTracks.row == {row}"
)
return
plt.track_id = track_id
session.commit()
class Settings(Base):
__tablename__ = 'settings'
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(32), nullable=False, unique=True)
f_datetime = Column(DateTime, default=None, nullable=True)
f_int = Column(Integer, default=None, nullable=True)
f_string = Column(String(128), default=None, nullable=True)
@classmethod
def get_int(cls, session, name):
try:
int_setting = session.query(cls).filter(
cls.name == name).one()
except NoResultFound:
int_setting = Settings()
int_setting.name = name
int_setting.f_int = None
session.add(int_setting)
session.commit()
return int_setting
def update(self, session, data):
for key, value in data.items():
assert hasattr(self, key)
setattr(self, key, value)
session.commit()
class Tracks(Base):
__tablename__ = 'tracks'
id = Column(Integer, primary_key=True, autoincrement=True)
title = Column(String(256), index=True)
artist = Column(String(256), index=True)
duration = Column(Integer, index=True)
start_gap = Column(Integer, index=False)
fade_at = Column(Integer, index=False)
silence_at = Column(Integer, index=False)
path = Column(String(2048), index=False, nullable=False)
mtime = Column(Float, index=True)
lastplayed = Column(DateTime, index=True, default=None)
playlists = relationship("PlaylistTracks", back_populates="tracks")
playdates = relationship("Playdates", back_populates="tracks")
def __repr__(self):
return (
f"<Track(id={self.id}, title={self.title}, "
f"artist={self.artist}, path={self.path}>"
)
# Not currently used 1 June 2021
# @staticmethod
# def get_note(session, id):
# return session.query(Notes).filter(Notes.id == id).one()
@staticmethod
def get_all_paths(session):
"Return a list of paths of all tracks"
return [a[0] for a in session.query(Tracks.path).all()]
@staticmethod
def get_all_tracks(session):
"Return a list of all tracks"
return session.query(Tracks).all()
@classmethod
def get_or_create(cls, session, path):
DEBUG(f"Tracks.get_or_create(path={path})")
try:
track = session.query(cls).filter(cls.path == path).one()
except NoResultFound:
track = Tracks()
track.path = path
session.add(track)
return track
@staticmethod
def get_duration(session, id):
try:
return session.query(
Tracks.duration).filter(Tracks.id == id).one()[0]
except NoResultFound:
ERROR(f"Can't find track id {id}")
return None
@staticmethod
def get_track_from_filename(session, filename):
"""
Return track if one and only one track in database has passed
filename (ie, basename of path). Return None if zero or more
than one track matches.
"""
DEBUG(f"Tracks.get_track_from_filename({filename=})")
try:
track = session.query(Tracks).filter(Tracks.path.ilike(
f'%{os.path.sep}{filename}')).one()
return track
except (NoResultFound, MultipleResultsFound):
return None
@staticmethod
def get_track_from_path(session, path):
"""
Return track with passee path, or None.
"""
DEBUG(f"Tracks.get_track_from_path({path=})")
return session.query(Tracks).filter(Tracks.path == path).first()
@staticmethod
def get_path(session, track_id):
"Return path of passed track_id, or None"
try:
return session.query(Tracks.path).filter(
Tracks.id == track_id).one()[0]
except NoResultFound:
ERROR(f"Can't find track id {track_id}")
return None
@staticmethod
def get_track(session, track_id):
"Return track or None"
try:
DEBUG(f"Tracks.get_track(track_id={track_id})")
track = session.query(Tracks).filter(Tracks.id == track_id).one()
return track
except NoResultFound:
ERROR(f"get_track({track_id}): not found")
return None
@staticmethod
def remove_path(session, path):
"Remove track with passed path from database"
DEBUG(f"Tracks.remove_path({path=})")
try:
session.query(Tracks).filter(Tracks.path == path).delete()
session.commit()
except IntegrityError as exception:
ERROR(f"Can't remove track with {path=} ({exception=})")
@staticmethod
def search(session, title=None, artist=None, duration=None):
"""
Return any tracks matching passed criteria
"""
DEBUG(
f"Tracks.search({title=}, {artist=}), {duration=})"
)
if not title and not artist and not duration:
return None
q = session.query(Tracks).filter(False)
if title:
q = q.filter(Tracks.title == title)
if artist:
q = q.filter(Tracks.artist == artist)
if duration:
q = q.filter(Tracks.duration == duration)
return q.all()
@staticmethod
def search_artists(session, text):
return (
session.query(Tracks)
.filter(Tracks.artist.ilike(f"%{text}%"))
.order_by(Tracks.title)
).all()
@staticmethod
def search_titles(session, text):
return (
session.query(Tracks)
.filter(Tracks.title.ilike(f"%{text}%"))
.order_by(Tracks.title)
).all()
@staticmethod
def track_from_id(session, id):
return session.query(Tracks).filter(
Tracks.id == id).one()
@staticmethod
def update_lastplayed(session, track_id):
track = session.query(Tracks).filter(Tracks.id == track_id).one()
track.lastplayed = datetime.now()
session.commit()
@staticmethod
def update_artist(session, track_id, artist):
track = session.query(Tracks).filter(Tracks.id == track_id).one()
track.artist = artist
session.commit()
@staticmethod
def update_title(session, track_id, title):
track = session.query(Tracks).filter(Tracks.id == track_id).one()
track.title = title
session.commit()
def update_path(self, newpath):
self.path = newpath

View File

@ -1,873 +0,0 @@
# Standard library imports
from __future__ import annotations
from typing import Optional, Sequence
import datetime as dt
import os
import re
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, ProgrammingError
from sqlalchemy.orm.exc import NoResultFound
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 log import log
# Establish database connection
DATABASE_URL = os.environ.get("DATABASE_URL")
if DATABASE_URL is None:
raise ValueError("DATABASE_URL is undefined")
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
# 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,
substring: str,
colour: str,
enabled: bool = True,
is_regex: bool = False,
is_casesensitive: bool = False,
order: Optional[int] = 0,
) -> None:
self.substring = substring
self.colour = colour
self.enabled = enabled
self.is_regex = is_regex
self.is_casesensitive = is_casesensitive
self.order = order
session.add(self)
session.commit()
@classmethod
def get_all(cls, session: Session) -> Sequence["NoteColours"]:
"""
Return all records
"""
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, foreground: bool = False
) -> str:
"""
Parse text and return background (foreground if foreground==True) colour
string if matched, else None
"""
if not text:
return ""
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):
match = True
else:
if rec.is_casesensitive:
if rec.substring in text:
match = True
else:
if rec.substring.lower() in text.lower():
match = True
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, when: Optional[dt.datetime] = None
) -> None:
"""Record that track was played"""
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: int = 5
) -> Sequence["Playdates"]:
"""
Return a list of the last limit playdates for this track, sorted
latest to earliest.
"""
return session.scalars(
Playdates.select()
.where(Playdates.track_id == track_id)
.order_by(Playdates.lastplayed.desc())
.limit(limit)
).all()
@staticmethod
def last_played(session: Session, track_id: int) -> dt.datetime:
"""Return datetime track last played or None"""
last_played = session.execute(
select(Playdates.lastplayed)
.where(Playdates.track_id == track_id)
.order_by(Playdates.lastplayed.desc())
.limit(1)
).first()
if last_played:
return last_played[0]
else:
# Should never be reached as we create record with a
# last_played value
return Config.EPOCH # pragma: no cover
@staticmethod
def last_played_tracks(session: Session, limit: int = 5) -> Sequence["Playdates"]:
"""
Return a list of the last limit tracks played, sorted
earliest to latest.
"""
return session.scalars(
Playdates.select().order_by(Playdates.lastplayed.desc()).limit(limit)
).all()
@staticmethod
def played_after(session: Session, since: dt.datetime) -> Sequence["Playdates"]:
"""Return a list of Playdates objects since passed time"""
return session.scalars(
select(Playdates)
.where(Playdates.lastplayed >= since)
.order_by(Playdates.lastplayed)
).all()
class Playlists(dbtables.PlaylistsTable):
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:
"""
Make all tab records NULL
"""
session.execute(
update(Playlists).where((Playlists.id.in_(playlist_ids))).values(tab=None)
)
def close(self, session: Session) -> None:
"""Mark playlist as unloaded"""
self.open = False
session.commit()
@classmethod
def get_all(cls, session: Session) -> Sequence["Playlists"]:
"""Returns a list of all playlists ordered by last use"""
return session.scalars(
select(cls)
.filter(cls.is_template.is_(False))
.order_by(cls.last_used.desc())
).all()
@classmethod
def get_all_templates(cls, session: Session) -> Sequence["Playlists"]:
"""Returns a list of all templates ordered by name"""
return session.scalars(
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
def get_closed(cls, session: Session) -> Sequence["Playlists"]:
"""Returns a list of all closed playlists ordered by last use"""
return session.scalars(
select(cls)
.filter(
cls.open.is_(False),
cls.is_template.is_(False),
)
.order_by(cls.last_used.desc())
).all()
@classmethod
def get_open(cls, session: Session) -> Sequence[Optional["Playlists"]]:
"""
Return a list of loaded playlists ordered by tab.
"""
return session.scalars(
select(cls).where(cls.open.is_(True)).order_by(cls.tab)
).all()
def mark_open(self) -> None:
"""Mark playlist as loaded and used now"""
self.open = True
self.last_used = dt.datetime.now()
@staticmethod
def name_is_available(session: Session, name: str) -> bool:
"""
Return True if no playlist of this name exists else false.
"""
return (
session.execute(select(Playlists).where(Playlists.name == name)).first()
is None
)
def rename(self, session: Session, new_name: str) -> None:
"""
Rename playlist
"""
self.name = new_name
session.commit()
@staticmethod
def save_as_template(
session: Session, playlist_id: int, template_name: str
) -> None:
"""Save passed playlist as new template"""
template = Playlists(session, template_name, template_id=0)
if not template or not template.id:
return
template.is_template = True
session.commit()
PlaylistRows.copy_playlist(session, playlist_id, template.id)
class PlaylistRows(dbtables.PlaylistRowsTable):
def __init__(
self,
session: Session,
playlist_id: int,
row_number: int,
note: str = "",
track_id: Optional[int] = None,
) -> None:
"""Create PlaylistRows object"""
self.playlist_id = playlist_id
self.track_id = track_id
self.row_number = row_number
self.note = note
session.add(self)
session.commit()
def append_note(self, extra_note: str) -> None:
"""Append passed note to any existing note"""
current_note = self.note
if current_note:
self.note = current_note + "\n" + extra_note
else:
self.note = extra_note
@staticmethod
def copy_playlist(session: Session, src_id: int, dst_id: int) -> None:
"""Copy playlist entries"""
src_rows = session.scalars(
select(PlaylistRows).filter(PlaylistRows.playlist_id == src_id)
).all()
for plr in src_rows:
PlaylistRows(
session=session,
playlist_id=dst_id,
row_number=plr.row_number,
note=plr.note,
track_id=plr.track_id,
)
@classmethod
def deep_row(
cls, session: Session, playlist_id: int, row_number: int
) -> "PlaylistRows":
"""
Return a playlist row that includes full track and lastplayed data for
given playlist_id and row
"""
stmt = (
select(PlaylistRows)
.options(joinedload(cls.track))
.where(
PlaylistRows.playlist_id == playlist_id,
PlaylistRows.row_number == row_number,
)
# .options(joinedload(Tracks.playdates))
)
return session.execute(stmt).unique().scalar_one()
@staticmethod
def delete_higher_rows(session: Session, playlist_id: int, maxrow: int) -> None:
"""
Delete rows in given playlist that have a higher row number
than 'maxrow'
"""
session.execute(
delete(PlaylistRows).where(
PlaylistRows.playlist_id == playlist_id,
PlaylistRows.row_number > maxrow,
)
)
session.commit()
@staticmethod
def delete_row(session: Session, playlist_id: int, row_number: int) -> None:
"""
Delete passed row in given playlist.
"""
session.execute(
delete(PlaylistRows).where(
PlaylistRows.playlist_id == playlist_id,
PlaylistRows.row_number == row_number,
)
)
@staticmethod
def fixup_rownumbers(session: Session, playlist_id: int) -> None:
"""
Ensure the row numbers for passed playlist have no gaps
"""
plrs = session.scalars(
select(PlaylistRows)
.where(PlaylistRows.playlist_id == playlist_id)
.order_by(PlaylistRows.row_number)
).all()
for i, plr in enumerate(plrs):
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]
) -> Sequence["PlaylistRows"]:
"""
Take a list of PlaylistRows ids and return a list of corresponding
PlaylistRows objects
"""
plrs = session.scalars(
select(cls)
.where(cls.playlist_id == playlist_id, cls.id.in_(plr_ids))
.order_by(cls.row_number)
).all()
return plrs
@staticmethod
def get_last_used_row(session: Session, playlist_id: int) -> Optional[int]:
"""Return the last used row for playlist, or None if no rows"""
return session.execute(
select(func.max(PlaylistRows.row_number)).where(
PlaylistRows.playlist_id == playlist_id
)
).scalar_one()
@staticmethod
def get_track_plr(
session: Session, track_id: int, playlist_id: int
) -> Optional["PlaylistRows"]:
"""Return first matching PlaylistRows object or None"""
return session.scalars(
select(PlaylistRows)
.where(
PlaylistRows.track_id == track_id,
PlaylistRows.playlist_id == playlist_id,
)
.limit(1)
).first()
@classmethod
def get_played_rows(
cls, session: Session, playlist_id: int
) -> Sequence["PlaylistRows"]:
"""
For passed playlist, return a list of rows that
have been played.
"""
plrs = session.scalars(
select(cls)
.where(cls.playlist_id == playlist_id, cls.played.is_(True))
.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,
session: Session,
playlist_id: int,
) -> Sequence["PlaylistRows"]:
"""
For passed playlist, return a list of rows that
contain tracks
"""
query = select(cls).where(
cls.playlist_id == playlist_id, cls.track_id.is_not(None)
)
plrs = session.scalars((query).order_by(cls.row_number)).all()
return plrs
@classmethod
def get_unplayed_rows(
cls, session: Session, playlist_id: int
) -> Sequence["PlaylistRows"]:
"""
For passed playlist, return a list of playlist rows that
have not been played.
"""
plrs = session.scalars(
select(cls)
.where(
cls.playlist_id == playlist_id,
cls.track_id.is_not(None),
cls.played.is_(False),
)
.order_by(cls.row_number)
).all()
return plrs
@classmethod
def insert_row(
cls,
session: Session,
playlist_id: int,
new_row_number: int,
note: str = "",
track_id: Optional[int] = None,
) -> "PlaylistRows":
cls.move_rows_down(session, playlist_id, new_row_number, 1)
return cls(
session,
playlist_id=playlist_id,
row_number=new_row_number,
note=note,
track_id=track_id,
)
@staticmethod
def move_rows_down(
session: Session, playlist_id: int, starting_row: int, move_by: int
) -> None:
"""
Create space to insert move_by additional rows by incremented row
number from starting_row to end of playlist
"""
log.debug(f"(move_rows_down({playlist_id=}, {starting_row=}, {move_by=}")
session.execute(
update(PlaylistRows)
.where(
(PlaylistRows.playlist_id == playlist_id),
(PlaylistRows.row_number >= starting_row),
)
.values(row_number=PlaylistRows.row_number + move_by)
)
@staticmethod
def update_plr_row_numbers(
session: Session,
playlist_id: int,
sqla_map: list[dict[str, int]],
) -> None:
"""
Take a {plrid: row_number} dictionary and update the row numbers accordingly
"""
# Update database. Ref:
# https://docs.sqlalchemy.org/en/20/tutorial/data_update.html#the-update-sql-expression-construct
stmt = (
update(PlaylistRows)
.where(
PlaylistRows.playlist_id == playlist_id,
PlaylistRows.id == bindparam("playlistrow_id"),
)
.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) -> None:
self.name = name
session.add(self)
session.commit()
@classmethod
def get_setting(cls, session: Session, name: str) -> "Settings":
"""Get existing setting or return new setting record"""
try:
return session.execute(select(cls).where(cls.name == name)).scalar_one()
except NoResultFound:
return Settings(session, name)
class Tracks(dbtables.TracksTable):
def __init__(
self,
session: Session,
path: str,
title: str,
artist: str,
duration: int,
start_gap: int,
fade_at: int,
silence_at: int,
bitrate: int,
) -> None:
self.path = path
self.title = title
self.artist = artist
self.bitrate = bitrate
self.duration = duration
self.start_gap = start_gap
self.fade_at = fade_at
self.silence_at = silence_at
try:
session.add(self)
session.commit()
except IntegrityError as error:
session.rollback()
log.error(f"Error ({error=}) importing track ({path=})")
raise ValueError(error)
@classmethod
def get_all(cls, session: Session) -> Sequence["Tracks"]:
"""Return a list of all tracks"""
return session.scalars(select(cls)).unique().all()
@classmethod
def all_tracks_indexed_by_id(cls, session: Session) -> dict[int, Tracks]:
"""
Return a dictionary of all tracks, keyed by title
"""
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"]:
"""
Return track with passed path, or None.
"""
try:
return (
session.execute(select(Tracks).where(Tracks.path == path))
.unique()
.scalar_one()
)
except NoResultFound:
return None
@classmethod
def search_artists(cls, session: Session, text: str) -> Sequence["Tracks"]:
"""
Search case-insenstively for artists containing str
The query performs an outer join with 'joinedload' to populate the results
from the Playdates table at the same time. unique() needed; see
https://docs.sqlalchemy.org/en/20/orm/queryguide/relationships.html#joined-eager-loading
"""
return (
session.scalars(
select(cls)
.options(joinedload(Tracks.playdates))
.where(cls.artist.ilike(f"%{text}%"))
.order_by(cls.title)
)
.unique()
.all()
)
@classmethod
def search_titles(cls, session: Session, text: str) -> Sequence["Tracks"]:
"""
Search case-insenstively for titles containing str
The query performs an outer join with 'joinedload' to populate the results
from the Playdates table at the same time. unique() needed; see
https://docs.sqlalchemy.org/en/20/orm/queryguide/relationships.html#joined-eager-loading
"""
return (
session.scalars(
select(cls)
.options(joinedload(Tracks.playdates))
.where(cls.title.like(f"{text}%"))
.order_by(cls.title)
)
.unique()
.all()
)

183
app/music.py Normal file
View File

@ -0,0 +1,183 @@
import os
import threading
import vlc
from config import Config
from datetime import datetime
from time import sleep
from log import DEBUG, ERROR
lock = threading.Lock()
class Music:
"""
Manage the playing of music tracks
"""
def __init__(self):
self.current_track_start_time = None
self.fading = 0
self.VLC = vlc.Instance()
self.player = None
self.track_path = None
self.max_volume = Config.VOLUME_VLC_DEFAULT
def fade(self):
"""
Fade the currently playing track.
The actual management of fading runs in its own thread so as not
to hold up the UI during the fade.
"""
DEBUG("music.fade()", True)
if not self.player:
return
if not self.player.get_position() > 0 and self.player.is_playing():
return
self.fading += 1
thread = threading.Thread(target=self._fade)
thread.start()
def _fade(self):
"""
Implementation of fading the current track in a separate thread.
"""
# Take a copy of current player to allow another track to be
# started without interfering here
DEBUG(f"music._fade(), {self.player=}", True)
with lock:
p = self.player
self.player = None
DEBUG("music._fade() post-lock", True)
fade_time = Config.FADE_TIME / 1000
steps = Config.FADE_STEPS
sleep_time = fade_time / steps
# We reduce volume by one mesure first, then by two measures,
# then three, and so on.
# The sum of the arithmetic sequence 1, 2, 3, ..n is
# (n**2 + n) / 2
total_measures_count = (steps**2 + steps) / 2
measures_to_reduce_by = 0
for i in range(1, steps + 1):
measures_to_reduce_by += i
volume_factor = 1 - (
measures_to_reduce_by / total_measures_count)
p.audio_set_volume(int(self.max_volume * volume_factor))
sleep(sleep_time)
with lock:
DEBUG(f"music._facde(), stopping {p=}", True)
p.stop()
DEBUG(f"Releasing player {p=}", True)
p.release()
# Ensure we don't reference player after release
p = None
self.fading -= 1
def get_playtime(self):
"Return elapsed play time"
with lock:
if not self.player:
return None
return self.player.get_time()
def get_position(self):
"Return current position"
with lock:
DEBUG("music.get_position", True)
print(f"get_position, {self.player=}")
if not self.player:
return
return self.player.get_position()
def play(self, path):
"""
Start playing the track at path.
Log and return if path not found.
"""
DEBUG(f"music.play({path=})", True)
if not os.access(path, os.R_OK):
ERROR(f"play({path}): path not found")
return
self.track_path = path
self.player = self.VLC.media_player_new(path)
self.player.audio_set_volume(self.max_volume)
DEBUG(f"music.play({path=}), {self.player}", True)
self.player.play()
self.current_track_start_time = datetime.now()
def playing(self):
"""
Return True if currently playing a track, else False
vlc.is_playing() returns True if track was faded out.
get_position seems more reliable.
"""
with lock:
if self.player:
if self.player.get_position() > 0 and self.player.is_playing():
return True
# We take a copy of the player when fading, so we could be
# playing in a fade nowFalse
return self.fading > 0
def set_position(self, ms):
"Set current play time in milliseconds from start"
with lock:
return self.player.set_time(ms)
def set_volume(self, volume):
"Set maximum volume used for player"
with lock:
if not self.player:
return
self.max_volume = volume
self.player.audio_set_volume(volume)
def stop(self):
"Immediately stop playing"
DEBUG(f"music.stop(), {self.player=}", True)
with lock:
if not self.player:
return
position = self.player.get_position()
self.player.stop()
DEBUG(f"music.stop(): Releasing player {self.player=}", True)
self.player.release()
# Ensure we don't reference player after release
self.player = None
return position

View File

@ -1,724 +0,0 @@
# Standard library imports
from __future__ import annotations
import datetime as dt
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,
QThread,
)
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 ApplicationError, MusicMusterSignals
from config import Config
import helpers
from log import log
from models import PlaylistRows
from vlcmanager import VLCManager
# 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):
"""
Initialising a fade curve introduces a noticeable delay so carry out in
a thread.
"""
finished = pyqtSignal()
def __init__(
self,
rat: RowAndTrack,
track_path: str,
track_fade_at: int,
track_silence_at: int,
) -> None:
super().__init__()
self.rat = rat
self.track_path = track_path
self.track_fade_at = track_fade_at
self.track_silence_at = track_silence_at
def run(self) -> None:
"""
Create fade curve and add to PlaylistTrack object
"""
fc = _FadeCurve(self.track_path, self.track_fade_at, self.track_silence_at)
if not fc:
log.error(f"Failed to create FadeCurve for {self.track_path=}")
else:
self.rat.fade_graph = fc
self.finished.emit()
class _FadeCurve:
GraphWidget: Optional[PlotWidget] = None
def __init__(
self, track_path: str, track_fade_at: int, track_silence_at: int
) -> None:
"""
Set up fade graph array
"""
audio = helpers.get_audio_segment(track_path)
if not audio:
log.error(f"FadeCurve: could not get audio for {track_path=}")
return None
# Start point of curve is Config.FADE_CURVE_MS_BEFORE_FADE
# milliseconds before fade starts to silence
self.start_ms: int = max(
0, track_fade_at - Config.FADE_CURVE_MS_BEFORE_FADE - 1
)
self.end_ms: int = track_silence_at
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)
self.curve: Optional[PlotDataItem] = None
self.region: Optional[LinearRegionItem] = None
def clear(self) -> None:
"""Clear the current graph"""
if self.GraphWidget:
self.GraphWidget.clear()
def plot(self) -> None:
if self.GraphWidget:
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: int) -> None:
"""Update volume fade curve"""
if not self.GraphWidget:
return
ms_of_graph = play_time - self.start_ms
if ms_of_graph < 0:
return
if self.region is None:
# Create the region now that we're into fade
self.region = pg.LinearRegionItem([0, 0], bounds=[0, len(self.graph_array)])
self.GraphWidget.addItem(self.region)
# Update region position
if self.region:
self.region.setRegion([0, ms_of_graph * self.ms_to_array_factor])
class _FadeTrack(QThread):
finished = pyqtSignal()
def __init__(self, player: vlc.MediaPlayer, fade_seconds: int) -> None:
super().__init__()
self.player = player
self.fade_seconds = fade_seconds
def run(self) -> None:
"""
Implementation of fading the player
"""
if not self.player:
return
# Reduce volume logarithmically
total_steps = self.fade_seconds * Config.FADEOUT_STEPS_PER_SECOND
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()
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.finished.emit()
# TODO can we move this into the _Music class?
vlc_instance = VLCManager().vlc_instance
class _Music:
"""
Manage the playing of music tracks
"""
def __init__(self, name: str) -> None:
vlc_instance.set_user_agent(name, name)
self.player: Optional[vlc.MediaPlayer] = None
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"""
if not self.player:
return
elapsed_ms = self.get_playtime()
position = self.get_position()
if not position:
position = 0.0
new_position = max(0.0, position + ((position * ms) / elapsed_ms))
self.set_position(new_position)
# Adjus start time so elapsed time calculations are correct
if new_position == 0:
self.start_dt = dt.datetime.now()
else:
if self.start_dt:
self.start_dt -= dt.timedelta(milliseconds=ms)
else:
self.start_dt = dt.datetime.now() - dt.timedelta(milliseconds=ms)
def fade(self, fade_seconds: int) -> None:
"""
Fade the currently playing track.
The actual management of fading runs in its own thread so as not
to hold up the UI during the fade.
"""
if not self.player:
return
if not self.player.get_position() > 0 and self.player.is_playing():
return
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:
"""
Return number of milliseconds current track has been playing or
zero if not playing. The vlc function get_time() only updates 3-4
times a second; this function has much better resolution.
"""
if self.start_dt is None:
return 0
now = dt.datetime.now()
elapsed_seconds = (now - self.start_dt).total_seconds()
return int(elapsed_seconds * 1000)
def get_position(self) -> Optional[float]:
"""Return current position"""
if not self.player:
return None
return self.player.get_position()
def is_playing(self) -> bool:
"""
Return True if we're playing
"""
if not self.player:
return False
# There is a discrete time between starting playing a track and
# player.is_playing() returning True, so assume playing if less
# than Config.PLAY_SETTLE microseconds have passed since
# starting play.
return self.start_dt is not None and (
self.player.is_playing()
or (dt.datetime.now() - self.start_dt)
< dt.timedelta(microseconds=Config.PLAY_SETTLE)
)
def play(
self,
path: str,
start_time: dt.datetime,
position: Optional[float] = None,
) -> None:
"""
Start playing the track at path.
Log and return if path not found.
start_time ensures our version and our caller's version of
the start time is the same
"""
log.debug(f"Music[{self.name}].play({path=}, {position=}")
if helpers.file_is_unreadable(path):
log.error(f"play({path}): path not readable")
return None
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.play()
self.set_volume(self.max_volume)
if position:
self.player.set_position(position)
self.start_dt = start_time
def set_position(self, position: float) -> None:
"""
Set player position
"""
if self.player:
self.player.set_position(position)
def set_volume(
self, volume: Optional[int] = None, set_default: bool = True
) -> None:
"""Set maximum volume used for player"""
if not self.player:
return
if set_default and volume:
self.max_volume = volume
if volume is None:
volume = Config.VLC_VOLUME_DEFAULT
self.player.audio_set_volume(volume)
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 RowAndTrack:
"""
Object to manage playlist rows and tracks.
"""
def __init__(self, playlist_row: PlaylistRows) -> None:
"""
Initialises data structure.
The passed PlaylistRows object will include a Tracks object if this
row has a track.
"""
# 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
# 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
# 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] = 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
# Other object initialisation
self.music = _Music(name=Config.VLC_MAIN_PLAYER_NAME)
self.signals = MusicMusterSignals()
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}>"
)
def check_for_end_of_track(self) -> None:
"""
Check whether track has ended. If so, emit track_ended_signal
"""
if self.start_time is None:
return
if self.end_of_track_signalled:
return
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:
"""
If enable is true, drop output by 3db else restore to full volume
"""
if enable:
self.music.set_volume(volume=Config.VLC_VOLUME_DROP3db, set_default=False)
else:
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.music.get_position()
self.music.fade(fade_seconds)
self.signals.track_ended_signal.emit()
def is_playing(self) -> bool:
"""
Return True if we're currently playing else False
"""
if self.start_time is None:
return False
return self.music.is_playing()
def move_back(self, ms: int = Config.PREVIEW_BACK_MS) -> None:
"""
Rewind player by ms milliseconds
"""
self.music.adjust_by_ms(ms * -1)
def move_forward(self, ms: int = Config.PREVIEW_ADVANCE_MS) -> None:
"""
Rewind player by ms milliseconds
"""
self.music.adjust_by_ms(ms)
def play(self, position: Optional[float] = None) -> None:
"""Play track"""
now = dt.datetime.now()
self.start_time = now
# Initialise player
self.music.play(self.path, start_time=now, position=position)
self.end_time = now + dt.timedelta(milliseconds=self.duration)
# Calculate time fade_graph should start updating
if self.fade_at:
update_graph_at_ms = max(
0, self.fade_at - Config.FADE_CURVE_MS_BEFORE_FADE - 1
)
self.fade_graph_start_updates = now + dt.timedelta(
milliseconds=update_graph_at_ms
)
def restart(self) -> None:
"""
Restart player
"""
self.music.adjust_by_ms(self.time_playing() * -1)
def set_forecast_start_time(
self, modified_rows: list[int], start: Optional[dt.datetime]
) -> Optional[dt.datetime]:
"""
Set forecast start time for this row
Update passed modified rows list if we changed the row.
Return new start time
"""
changed = False
if self.forecast_start_time != start:
self.forecast_start_time = start
changed = True
if start is None:
if self.forecast_end_time is not None:
self.forecast_end_time = None
changed = True
new_start_time = None
else:
end_time = start + dt.timedelta(milliseconds=self.duration)
new_start_time = end_time
if self.forecast_end_time != end_time:
self.forecast_end_time = end_time
changed = True
if changed and self.row_number not in modified_rows:
modified_rows.append(self.row_number)
return new_start_time
def stop(self, fade_seconds: int = 0) -> None:
"""
Stop this track playing
"""
self.resume_marker = self.music.get_position()
self.fade(fade_seconds)
# Reset fade graph
if self.fade_graph:
self.fade_graph.clear()
def time_playing(self) -> int:
"""
Return time track has been playing in milliseconds, zero if not playing
"""
if self.start_time is None:
return 0
return self.music.get_playtime()
def time_remaining_intro(self) -> int:
"""
Return milliseconds of intro remaining. Return 0 if no intro time in track
record or if intro has finished.
"""
if not self.intro:
return 0
return max(0, self.intro - self.time_playing())
def time_to_fade(self) -> int:
"""
Return milliseconds until fade time. Return zero if we're not playing.
"""
if self.start_time is None:
return 0
return self.fade_at - self.time_playing()
def time_to_silence(self) -> int:
"""
Return milliseconds until silent. Return zero if we're not playing.
"""
if self.start_time is None:
return 0
return self.silence_at - self.time_playing()
def update_fade_graph(self) -> None:
"""
Update fade graph
"""
if (
not self.is_playing()
or not self.fade_graph_start_updates
or not self.fade_graph
):
return
now = dt.datetime.now()
if self.fade_graph_start_updates > now:
return
self.fade_graph.tick(self.time_playing())
def update_playlist_and_row(self, session: Session) -> None:
"""
Update local playlist_id and row_number from playlistrow_id
"""
plr = session.get(PlaylistRows, self.playlistrow_id)
if not plr:
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[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()

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

415
app/songdb.py Executable file
View File

@ -0,0 +1,415 @@
#!/usr/bin/env python
import argparse
import os
import shutil
import tempfile
from config import Config
from helpers import show_warning
from log import DEBUG, INFO
from model import Notes, Playdates, PlaylistTracks, Session, Tracks
from mutagen.flac import FLAC
from mutagen.mp3 import MP3
from pydub import AudioSegment, effects
from tinytag import TinyTag
# Globals (I know)
messages = []
def main():
"Main loop"
DEBUG("Starting")
# Parse command line
p = argparse.ArgumentParser()
# Only allow one option to be specified
group = p.add_mutually_exclusive_group()
group.add_argument('-u', '--update',
action="store_true", dest="update",
default=False, help="Update database")
group.add_argument('-f', '--full-update',
action="store_true", dest="full_update",
default=False, help="Update database")
group.add_argument('-i', '--import', dest="fname", help="Input file")
args = p.parse_args()
# Run as required
if args.update:
DEBUG("Updating database")
with Session() as session:
update_db(session)
elif args.full_update:
DEBUG("Full update of database")
with Session() as session:
full_update_db(session)
elif args.fname:
fname = os.path.realpath(args.fname)
with Session() as session:
create_track_from_file(session, fname, interactive=True)
else:
INFO("No action specified")
DEBUG("Finished")
def create_track_from_file(session, path, interactive=False):
"""
Create track in database from passed path, or update database entry
if path already in database.
Return track.
"""
if interactive:
str = f"Importing {path}"
INFO(str)
INFO("-" * len(str))
INFO("Get track info...")
t = get_music_info(path)
title = t['title']
artist = t['artist']
if interactive:
INFO(f" Title: \"{title}\"")
INFO(f" Artist: \"{artist}\"")
# Check for duplicate
tracks = Tracks.search_titles(session, title)
if interactive and tracks:
print("Found the following possible matches:")
for track in tracks:
print(f'"{track.title}" by {track.artist}')
response = input("Continue [c] or abort [a]?")
if not response:
return
if response[0].lower() not in ['c', 'y']:
return
track = Tracks.get_or_create(session, path)
track.title = title
track.artist = artist
track.duration = int(round(
t['duration'], Config.MILLISECOND_SIGFIGS) * 1000)
if interactive:
INFO("Parse for start, fade and silence...")
audio = get_audio_segment(path)
track.start_gap = leading_silence(audio)
track.fade_at = round(fade_point(audio) / 1000,
Config.MILLISECOND_SIGFIGS) * 1000
track.silence_at = round(trailing_silence(audio) / 1000,
Config.MILLISECOND_SIGFIGS) * 1000
track.mtime = os.path.getmtime(path)
session.commit()
if Config.NORMALISE_ON_IMPORT:
if interactive:
INFO("Normalise...")
# Check type
ftype = os.path.splitext(path)[1][1:]
if ftype not in ['mp3', 'flac']:
INFO(f"File type {ftype} not implemented")
return track
# Get current file gid, uid and permissions
stats = os.stat(path)
try:
# Copy original file
fd, temp_path = tempfile.mkstemp()
shutil.copyfile(path, temp_path)
except Exception as err:
DEBUG(f"songdb.create_track_from_file({path}): err1: {str(err)}")
return
# Overwrite original file with normalised output
normalised = effects.normalize(audio)
try:
normalised.export(path, format=os.path.splitext(path)[1][1:])
# Fix up permssions and ownership
os.chown(path, stats.st_uid, stats.st_gid)
os.chmod(path, stats.st_mode)
# Copy tags
if ftype == 'flac':
tag_handler = FLAC
elif ftype == 'mp3':
tag_handler = MP3
else:
return track
src = tag_handler(temp_path)
dst = tag_handler(path)
for tag in src:
dst[tag] = src[tag]
dst.save()
except Exception as err:
DEBUG(f"songdb.create_track_from_file({path}): err2: {str(err)}")
# Restore original file
shutil.copyfile(path, temp_path)
finally:
if os.path.exists(temp_path):
os.remove(temp_path)
return track
def full_update_db(session):
"Rescan all entries in database"
def log(msg):
INFO(f"full_update_db(): {msg}")
def check_change(track_id, title, attribute, old, new):
if new > (old * 1.1) or new < (old * 0.9):
log(
"\n"
f"track[{track_id}] ({title}) "
f"{attribute} updated from {old} to {new}"
)
# Start with normal update to add new tracks and remove any missing
# files
log("update_db()")
update_db(session)
# Now update track length, silence and fade for every track in
# database
tracks = Tracks.get_all_tracks(session)
total_tracks = len(tracks)
log(f"Processing {total_tracks} tracks")
track_count = 0
for track in tracks:
track_count += 1
print(f"\rTrack {track_count} of {total_tracks}", end='')
# Sanity check
tag = get_music_info(track.path)
if not tag['title']:
log(f"track[{track.id}] {track.title=}: No tag title")
continue
if not tag['artist']:
log(f"track[{track.id}] {track.artist=}: No tag artist")
continue
# Update title and artist
if track.title != tag['title']:
track.title = tag['title']
if track.artist != tag['artist']:
track.artist = tag['artist']
# Update numbers; log if more than 10% different
duration = int(round(
tag['duration'], Config.MILLISECOND_SIGFIGS) * 1000)
check_change(track.id, track.title, "duration", track.duration,
duration)
track.duration = duration
audio = get_audio_segment(track.path)
start_gap = leading_silence(audio)
check_change(track.id, track.title, "start_gap", track.start_gap,
start_gap)
track.start_gap = start_gap
fade_at = fade_point(audio)
check_change(track.id, track.title, "fade_at", track.fade_at,
fade_at)
track.fade_at = fade_at
silence_at = trailing_silence(audio)
check_change(track.id, track.title, "silence_at", track.silence_at,
silence_at)
track.silence_at = silence_at
session.commit()
def get_audio_segment(path):
try:
if path.endswith('.mp3'):
return AudioSegment.from_mp3(path)
elif path.endswith('.flac'):
return AudioSegment.from_file(path, "flac")
except AttributeError:
return None
def get_music_info(path):
"""
Return a dictionary of title, artist, duration-in-milliseconds and path.
"""
tag = TinyTag.get(path)
return dict(
title=tag.title,
artist=tag.artist,
duration=tag.duration,
path=path
)
def leading_silence(audio_segment, silence_threshold=Config.DBFS_SILENCE,
chunk_size=Config.AUDIO_SEGMENT_CHUNK_SIZE):
"""
Returns the millisecond/index that the leading silence ends.
audio_segment - the segment to find silence in
silence_threshold - the upper bound for how quiet is silent in dFBS
chunk_size - chunk size for interating over the segment in ms
https://github.com/jiaaro/pydub/blob/master/pydub/silence.py
"""
trim_ms = 0 # ms
assert chunk_size > 0 # to avoid infinite loop
while (
audio_segment[trim_ms:trim_ms + chunk_size].dBFS < # noqa W504
silence_threshold and trim_ms < len(audio_segment)):
trim_ms += chunk_size
# if there is no end it should return the length of the segment
return min(trim_ms, len(audio_segment))
def fade_point(audio_segment, fade_threshold=0,
chunk_size=Config.AUDIO_SEGMENT_CHUNK_SIZE):
"""
Returns the millisecond/index of the point where the volume drops below
the maximum and doesn't get louder again.
audio_segment - the sdlg_search_database_uiegment to find silence in
fade_threshold - the upper bound for how quiet is silent in dFBS
chunk_size - chunk size for interating over the segment in ms
"""
assert chunk_size > 0 # to avoid infinite loop
segment_length = audio_segment.duration_seconds * 1000 # ms
trim_ms = segment_length - chunk_size
max_vol = audio_segment.dBFS
if fade_threshold == 0:
fade_threshold = max_vol
while (
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
# the chunk_size, but for chunk_size = 10ms, this may be ignored)
return int(trim_ms)
def trailing_silence(audio_segment, silence_threshold=-50.0,
chunk_size=Config.AUDIO_SEGMENT_CHUNK_SIZE):
return fade_point(audio_segment, silence_threshold, chunk_size)
def update_db(session):
"""
Repopulate database
"""
# Search for tracks in only one of directory and database
db_paths = set(Tracks.get_all_paths(session))
os_paths_list = []
for root, dirs, files in os.walk(Config.ROOT):
for f in files:
path = os.path.join(root, f)
ext = os.path.splitext(f)[1]
if ext in [".flac", ".mp3"]:
os_paths_list.append(path)
os_paths = set(os_paths_list)
# If a track is moved, only the path will have changed.
# For any files we have found whose paths are not in the database,
# check to see whether the filename (basename) is present in the
# database:
for path in list(os_paths - db_paths):
DEBUG(f"songdb.update_db: {path=} not in database")
# is filename in database?
track = Tracks.get_track_from_filename(session, os.path.basename(path))
if not track:
messages.append(f"Track missing from database: {path}")
else:
# Check track info matches found track
t = get_music_info(path)
if t['artist'] == track.artist and t['title'] == track.title:
track.update_path(path)
else:
create_track_from_file(session, path)
# Refresh database paths
db_paths = set(Tracks.get_all_paths(session))
# Remote any tracks from database whose paths don't exist
for path in list(db_paths - os_paths):
# Manage tracks listed in database but where path is invalid
track = Tracks.get_track_from_path(session, path)
messages.append(f"Remove from database: {path=} {track=}")
# Remove references from Playdates
Playdates.remove_track(session, track.id)
# Replace playlist entries with a note
note_txt = (
f"File removed: {track.title=}, {track.artist=}, "
f"{track.path=}"
)
for pt in PlaylistTracks.get_track_playlists(session, track.id):
# Create note
Notes.add_note(session, pt.playlist_id, pt.row, note_txt)
# Remove playlist entry
PlaylistTracks.remove_track(session, pt.playlist_id, pt.row)
# Remove Track entry pointing to invalid path
Tracks.remove_path(session, path)
# Output messages (so if running via cron, these will get sent to
# user)
if messages:
print("Messages")
print("\n".join(messages))
def update_meta(session, track, artist=None, title=None):
"""
Updates both the tag info in the file and the database entry with passed
artist and tag details.
"""
DEBUG(f"songdb.update_meta({session=}, {track=}, {artist=}, {title=})")
if not artist and not title:
return
ftype = os.path.splitext(track.path)[1][1:]
if ftype == 'flac':
tag_handler = FLAC
elif ftype == 'mp3':
tag_handler = MP3
else:
INFO(f"File type {ftype} not implemented")
return
# Update tags
f = tag_handler(track.path)
try:
if artist:
f["artist"] = artist
if title:
f["title"] = title
f.save()
except TypeError:
show_warning("TAG error", "Can't update tag. Try editing in Audacity")
# Update database
with Session() as session:
if artist:
Tracks.update_artist(session, track.id, artist)
if title:
Tracks.update_title(session, track.id, title)
if __name__ == '__main__' and '__file__' in globals():
main()

View File

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

View File

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

View File

@ -1,145 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>Dialog</class>
<widget class="QDialog" name="Dialog">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>1038</width>
<height>774</height>
</rect>
</property>
<property name="windowTitle">
<string>Dialog</string>
</property>
<widget class="QDialogButtonBox" name="buttonBox">
<property name="geometry">
<rect>
<x>680</x>
<y>730</y>
<width>341</width>
<height>32</height>
</rect>
</property>
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="standardButtons">
<set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
</property>
</widget>
<widget class="QLabel" name="label">
<property name="geometry">
<rect>
<x>10</x>
<y>15</y>
<width>181</width>
<height>24</height>
</rect>
</property>
<property name="text">
<string>Source directory:</string>
</property>
<property name="alignment">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property>
</widget>
<widget class="QLabel" name="label_2">
<property name="geometry">
<rect>
<x>10</x>
<y>50</y>
<width>181</width>
<height>24</height>
</rect>
</property>
<property name="text">
<string>Destination directory:</string>
</property>
<property name="alignment">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property>
</widget>
<widget class="QLabel" name="lblSourceDirectory">
<property name="geometry">
<rect>
<x>200</x>
<y>15</y>
<width>811</width>
<height>24</height>
</rect>
</property>
<property name="text">
<string>lblSourceDirectory</string>
</property>
</widget>
<widget class="QLabel" name="lblDestinationDirectory">
<property name="geometry">
<rect>
<x>200</x>
<y>50</y>
<width>811</width>
<height>24</height>
</rect>
</property>
<property name="text">
<string>lblDestinationDirectory</string>
</property>
</widget>
<widget class="QTableWidget" name="tableWidget">
<property name="geometry">
<rect>
<x>20</x>
<y>90</y>
<width>1001</width>
<height>621</height>
</rect>
</property>
<property name="alternatingRowColors">
<bool>true</bool>
</property>
<property name="columnCount">
<number>3</number>
</property>
<column/>
<column/>
<column/>
</widget>
</widget>
<resources/>
<connections>
<connection>
<sender>buttonBox</sender>
<signal>accepted()</signal>
<receiver>Dialog</receiver>
<slot>accept()</slot>
<hints>
<hint type="sourcelabel">
<x>248</x>
<y>254</y>
</hint>
<hint type="destinationlabel">
<x>157</x>
<y>274</y>
</hint>
</hints>
</connection>
<connection>
<sender>buttonBox</sender>
<signal>rejected()</signal>
<receiver>Dialog</receiver>
<slot>reject()</slot>
<hints>
<hint type="sourcelabel">
<x>316</x>
<y>260</y>
</hint>
<hint type="destinationlabel">
<x>286</x>
<y>274</y>
</hint>
</hints>
</connection>
</connections>
</ui>

View File

@ -1,164 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>DialogCartEdit</class>
<widget class="QDialog" name="DialogCartEdit">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>564</width>
<height>148</height>
</rect>
</property>
<property name="windowTitle">
<string>Carts</string>
</property>
<layout class="QGridLayout" name="gridLayout">
<item row="0" column="0">
<widget class="QLabel" name="label">
<property name="maximumSize">
<size>
<width>56</width>
<height>16777215</height>
</size>
</property>
<property name="text">
<string>&amp;Name:</string>
</property>
<property name="buddy">
<cstring>lineEditName</cstring>
</property>
</widget>
</item>
<item row="0" column="1" colspan="2">
<widget class="QLineEdit" name="lineEditName">
<property name="inputMask">
<string/>
</property>
</widget>
</item>
<item row="0" column="3">
<widget class="QCheckBox" name="chkEnabled">
<property name="text">
<string>&amp;Enabled</string>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label_2">
<property name="maximumSize">
<size>
<width>56</width>
<height>16777215</height>
</size>
</property>
<property name="text">
<string>File:</string>
</property>
<property name="buddy">
<cstring>lblPath</cstring>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QLabel" name="lblPath">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>301</width>
<height>41</height>
</size>
</property>
<property name="text">
<string/>
</property>
<property name="textFormat">
<enum>Qt::PlainText</enum>
</property>
<property name="alignment">
<set>Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop</set>
</property>
<property name="wordWrap">
<bool>true</bool>
</property>
</widget>
</item>
<item row="1" column="3">
<widget class="QPushButton" name="btnFile">
<property name="maximumSize">
<size>
<width>31</width>
<height>16777215</height>
</size>
</property>
<property name="text">
<string>...</string>
</property>
</widget>
</item>
<item row="2" column="1">
<spacer name="horizontalSpacer">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>116</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item row="2" column="2" colspan="2">
<widget class="QDialogButtonBox" name="buttonBox">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="standardButtons">
<set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
</property>
</widget>
</item>
</layout>
</widget>
<resources/>
<connections>
<connection>
<sender>buttonBox</sender>
<signal>accepted()</signal>
<receiver>DialogCartEdit</receiver>
<slot>accept()</slot>
<hints>
<hint type="sourcelabel">
<x>248</x>
<y>254</y>
</hint>
<hint type="destinationlabel">
<x>157</x>
<y>274</y>
</hint>
</hints>
</connection>
<connection>
<sender>buttonBox</sender>
<signal>rejected()</signal>
<receiver>DialogCartEdit</receiver>
<slot>reject()</slot>
<hints>
<hint type="sourcelabel">
<x>316</x>
<y>260</y>
</hint>
<hint type="destinationlabel">
<x>286</x>
<y>274</y>
</hint>
</hints>
</connection>
</connections>
</ui>

View File

@ -13,6 +13,8 @@
<property name="windowTitle">
<string>Dialog</string>
</property>
<layout class="QGridLayout" name="gridLayout_2">
<item row="0" column="0">
<layout class="QGridLayout" name="gridLayout">
<item row="0" column="0">
<widget class="QLabel" name="label">
@ -24,50 +26,13 @@
<item row="0" column="1">
<widget class="QLineEdit" name="searchString"/>
</item>
<item row="1" column="0" colspan="2">
<widget class="QListWidget" name="matchList"/>
</item>
<item row="2" column="0" colspan="2">
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<widget class="QLabel" name="lblNote">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="maximumSize">
<size>
<width>46</width>
<height>16777215</height>
</size>
</property>
<property name="text">
<string>&amp;Note:</string>
</property>
<property name="alignment">
<set>Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop</set>
</property>
<property name="buddy">
<cstring>txtNote</cstring>
</property>
</widget>
</item>
<item>
<widget class="QLineEdit" name="txtNote"/>
</item>
</layout>
</item>
<item row="3" column="0" colspan="2">
<widget class="QLabel" name="dbPath">
<property name="text">
<string/>
</property>
</widget>
<item row="1" column="0">
<widget class="QListWidget" name="matchList"/>
</item>
<item row="4" column="0" colspan="2">
<layout class="QHBoxLayout" name="horizontalLayout_2">
<item row="2" column="0">
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<widget class="QRadioButton" name="radioTitle">
<property name="text">
@ -124,6 +89,13 @@
</item>
</layout>
</item>
<item row="3" column="0">
<widget class="QLabel" name="dbPath">
<property name="text">
<string/>
</property>
</widget>
</item>
</layout>
</widget>
<resources/>

View File

@ -1,13 +1,12 @@
# Form implementation generated from reading ui file 'app/ui/dlg_SelectPlaylist.ui'
# -*- coding: utf-8 -*-
# Form implementation generated from reading ui file 'ui/dlg_SelectPlaylist.ui'
#
# Created by: PyQt6 UI code generator 6.5.0
# Created by: PyQt5 UI code generator 5.11.3
#
# 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
# WARNING! All changes made in this file will be lost!
from PyQt5 import QtCore, QtGui, QtWidgets
class Ui_dlgSelectPlaylist(object):
def setupUi(self, dlgSelectPlaylist):
@ -15,20 +14,21 @@ class Ui_dlgSelectPlaylist(object):
dlgSelectPlaylist.resize(276, 150)
self.verticalLayout = QtWidgets.QVBoxLayout(dlgSelectPlaylist)
self.verticalLayout.setObjectName("verticalLayout")
self.lstPlaylists = QtWidgets.QListWidget(parent=dlgSelectPlaylist)
self.lstPlaylists = QtWidgets.QListWidget(dlgSelectPlaylist)
self.lstPlaylists.setObjectName("lstPlaylists")
self.verticalLayout.addWidget(self.lstPlaylists)
self.buttonBox = QtWidgets.QDialogButtonBox(parent=dlgSelectPlaylist)
self.buttonBox.setOrientation(QtCore.Qt.Orientation.Horizontal)
self.buttonBox.setStandardButtons(QtWidgets.QDialogButtonBox.StandardButton.Cancel|QtWidgets.QDialogButtonBox.StandardButton.Ok)
self.buttonBox = QtWidgets.QDialogButtonBox(dlgSelectPlaylist)
self.buttonBox.setOrientation(QtCore.Qt.Horizontal)
self.buttonBox.setStandardButtons(QtWidgets.QDialogButtonBox.Cancel|QtWidgets.QDialogButtonBox.Ok)
self.buttonBox.setObjectName("buttonBox")
self.verticalLayout.addWidget(self.buttonBox)
self.retranslateUi(dlgSelectPlaylist)
self.buttonBox.accepted.connect(dlgSelectPlaylist.accept) # type: ignore
self.buttonBox.rejected.connect(dlgSelectPlaylist.reject) # type: ignore
self.buttonBox.accepted.connect(dlgSelectPlaylist.accept)
self.buttonBox.rejected.connect(dlgSelectPlaylist.reject)
QtCore.QMetaObject.connectSlotsByName(dlgSelectPlaylist)
def retranslateUi(self, dlgSelectPlaylist):
_translate = QtCore.QCoreApplication.translate
dlgSelectPlaylist.setWindowTitle(_translate("dlgSelectPlaylist", "Dialog"))

View File

@ -1,83 +0,0 @@
# Form implementation generated from reading ui file 'dlg_TrackSelect.ui'
#
# Created by: PyQt6 UI code generator 6.5.3
#
# 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_Dialog(object):
def setupUi(self, Dialog):
Dialog.setObjectName("Dialog")
Dialog.resize(584, 377)
self.gridLayout = QtWidgets.QGridLayout(Dialog)
self.gridLayout.setObjectName("gridLayout")
self.label = QtWidgets.QLabel(parent=Dialog)
self.label.setObjectName("label")
self.gridLayout.addWidget(self.label, 0, 0, 1, 1)
self.searchString = QtWidgets.QLineEdit(parent=Dialog)
self.searchString.setObjectName("searchString")
self.gridLayout.addWidget(self.searchString, 0, 1, 1, 1)
self.matchList = QtWidgets.QListWidget(parent=Dialog)
self.matchList.setObjectName("matchList")
self.gridLayout.addWidget(self.matchList, 1, 0, 1, 2)
self.horizontalLayout = QtWidgets.QHBoxLayout()
self.horizontalLayout.setObjectName("horizontalLayout")
self.lblNote = QtWidgets.QLabel(parent=Dialog)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Fixed, QtWidgets.QSizePolicy.Policy.Preferred)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.lblNote.sizePolicy().hasHeightForWidth())
self.lblNote.setSizePolicy(sizePolicy)
self.lblNote.setMaximumSize(QtCore.QSize(46, 16777215))
self.lblNote.setAlignment(QtCore.Qt.AlignmentFlag.AlignLeading|QtCore.Qt.AlignmentFlag.AlignLeft|QtCore.Qt.AlignmentFlag.AlignTop)
self.lblNote.setObjectName("lblNote")
self.horizontalLayout.addWidget(self.lblNote)
self.txtNote = QtWidgets.QLineEdit(parent=Dialog)
self.txtNote.setObjectName("txtNote")
self.horizontalLayout.addWidget(self.txtNote)
self.gridLayout.addLayout(self.horizontalLayout, 2, 0, 1, 2)
self.dbPath = QtWidgets.QLabel(parent=Dialog)
self.dbPath.setText("")
self.dbPath.setObjectName("dbPath")
self.gridLayout.addWidget(self.dbPath, 3, 0, 1, 2)
self.horizontalLayout_2 = QtWidgets.QHBoxLayout()
self.horizontalLayout_2.setObjectName("horizontalLayout_2")
self.radioTitle = QtWidgets.QRadioButton(parent=Dialog)
self.radioTitle.setChecked(True)
self.radioTitle.setObjectName("radioTitle")
self.horizontalLayout_2.addWidget(self.radioTitle)
self.radioArtist = QtWidgets.QRadioButton(parent=Dialog)
self.radioArtist.setObjectName("radioArtist")
self.horizontalLayout_2.addWidget(self.radioArtist)
spacerItem = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum)
self.horizontalLayout_2.addItem(spacerItem)
self.btnAdd = QtWidgets.QPushButton(parent=Dialog)
self.btnAdd.setDefault(True)
self.btnAdd.setObjectName("btnAdd")
self.horizontalLayout_2.addWidget(self.btnAdd)
self.btnAddClose = QtWidgets.QPushButton(parent=Dialog)
self.btnAddClose.setObjectName("btnAddClose")
self.horizontalLayout_2.addWidget(self.btnAddClose)
self.btnClose = QtWidgets.QPushButton(parent=Dialog)
self.btnClose.setObjectName("btnClose")
self.horizontalLayout_2.addWidget(self.btnClose)
self.gridLayout.addLayout(self.horizontalLayout_2, 4, 0, 1, 2)
self.lblNote.setBuddy(self.txtNote)
self.retranslateUi(Dialog)
QtCore.QMetaObject.connectSlotsByName(Dialog)
def retranslateUi(self, Dialog):
_translate = QtCore.QCoreApplication.translate
Dialog.setWindowTitle(_translate("Dialog", "Dialog"))
self.label.setText(_translate("Dialog", "Title:"))
self.lblNote.setText(_translate("Dialog", "&Note:"))
self.radioTitle.setText(_translate("Dialog", "&Title"))
self.radioArtist.setText(_translate("Dialog", "&Artist"))
self.btnAdd.setText(_translate("Dialog", "&Add"))
self.btnAddClose.setText(_translate("Dialog", "A&dd and close"))
self.btnClose.setText(_translate("Dialog", "&Close"))

View File

@ -1,71 +0,0 @@
# Form implementation generated from reading ui file 'dlg_Cart.ui'
#
# Created by: PyQt6 UI code generator 6.5.3
#
# 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_DialogCartEdit(object):
def setupUi(self, DialogCartEdit):
DialogCartEdit.setObjectName("DialogCartEdit")
DialogCartEdit.resize(564, 148)
self.gridLayout = QtWidgets.QGridLayout(DialogCartEdit)
self.gridLayout.setObjectName("gridLayout")
self.label = QtWidgets.QLabel(parent=DialogCartEdit)
self.label.setMaximumSize(QtCore.QSize(56, 16777215))
self.label.setObjectName("label")
self.gridLayout.addWidget(self.label, 0, 0, 1, 1)
self.lineEditName = QtWidgets.QLineEdit(parent=DialogCartEdit)
self.lineEditName.setInputMask("")
self.lineEditName.setObjectName("lineEditName")
self.gridLayout.addWidget(self.lineEditName, 0, 1, 1, 2)
self.chkEnabled = QtWidgets.QCheckBox(parent=DialogCartEdit)
self.chkEnabled.setObjectName("chkEnabled")
self.gridLayout.addWidget(self.chkEnabled, 0, 3, 1, 1)
self.label_2 = QtWidgets.QLabel(parent=DialogCartEdit)
self.label_2.setMaximumSize(QtCore.QSize(56, 16777215))
self.label_2.setObjectName("label_2")
self.gridLayout.addWidget(self.label_2, 1, 0, 1, 1)
self.lblPath = QtWidgets.QLabel(parent=DialogCartEdit)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Preferred, QtWidgets.QSizePolicy.Policy.Preferred)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.lblPath.sizePolicy().hasHeightForWidth())
self.lblPath.setSizePolicy(sizePolicy)
self.lblPath.setMinimumSize(QtCore.QSize(301, 41))
self.lblPath.setText("")
self.lblPath.setTextFormat(QtCore.Qt.TextFormat.PlainText)
self.lblPath.setAlignment(QtCore.Qt.AlignmentFlag.AlignLeading|QtCore.Qt.AlignmentFlag.AlignLeft|QtCore.Qt.AlignmentFlag.AlignTop)
self.lblPath.setWordWrap(True)
self.lblPath.setObjectName("lblPath")
self.gridLayout.addWidget(self.lblPath, 1, 1, 1, 1)
self.btnFile = QtWidgets.QPushButton(parent=DialogCartEdit)
self.btnFile.setMaximumSize(QtCore.QSize(31, 16777215))
self.btnFile.setObjectName("btnFile")
self.gridLayout.addWidget(self.btnFile, 1, 3, 1, 1)
spacerItem = QtWidgets.QSpacerItem(116, 20, QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum)
self.gridLayout.addItem(spacerItem, 2, 1, 1, 1)
self.buttonBox = QtWidgets.QDialogButtonBox(parent=DialogCartEdit)
self.buttonBox.setOrientation(QtCore.Qt.Orientation.Horizontal)
self.buttonBox.setStandardButtons(QtWidgets.QDialogButtonBox.StandardButton.Cancel|QtWidgets.QDialogButtonBox.StandardButton.Ok)
self.buttonBox.setObjectName("buttonBox")
self.gridLayout.addWidget(self.buttonBox, 2, 2, 1, 2)
self.label.setBuddy(self.lineEditName)
self.label_2.setBuddy(self.lblPath)
self.retranslateUi(DialogCartEdit)
self.buttonBox.accepted.connect(DialogCartEdit.accept) # type: ignore
self.buttonBox.rejected.connect(DialogCartEdit.reject) # type: ignore
QtCore.QMetaObject.connectSlotsByName(DialogCartEdit)
def retranslateUi(self, DialogCartEdit):
_translate = QtCore.QCoreApplication.translate
DialogCartEdit.setWindowTitle(_translate("DialogCartEdit", "Carts"))
self.label.setText(_translate("DialogCartEdit", "&Name:"))
self.chkEnabled.setText(_translate("DialogCartEdit", "&Enabled"))
self.label_2.setText(_translate("DialogCartEdit", "File:"))
self.btnFile.setText(_translate("DialogCartEdit", "..."))

View File

@ -1,53 +0,0 @@
# Form implementation generated from reading ui file 'app/ui/dlgReplaceFiles.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_Dialog(object):
def setupUi(self, Dialog):
Dialog.setObjectName("Dialog")
Dialog.resize(1038, 774)
self.buttonBox = QtWidgets.QDialogButtonBox(parent=Dialog)
self.buttonBox.setGeometry(QtCore.QRect(680, 730, 341, 32))
self.buttonBox.setOrientation(QtCore.Qt.Orientation.Horizontal)
self.buttonBox.setStandardButtons(QtWidgets.QDialogButtonBox.StandardButton.Cancel|QtWidgets.QDialogButtonBox.StandardButton.Ok)
self.buttonBox.setObjectName("buttonBox")
self.label = QtWidgets.QLabel(parent=Dialog)
self.label.setGeometry(QtCore.QRect(10, 15, 181, 24))
self.label.setAlignment(QtCore.Qt.AlignmentFlag.AlignRight|QtCore.Qt.AlignmentFlag.AlignTrailing|QtCore.Qt.AlignmentFlag.AlignVCenter)
self.label.setObjectName("label")
self.label_2 = QtWidgets.QLabel(parent=Dialog)
self.label_2.setGeometry(QtCore.QRect(10, 50, 181, 24))
self.label_2.setAlignment(QtCore.Qt.AlignmentFlag.AlignRight|QtCore.Qt.AlignmentFlag.AlignTrailing|QtCore.Qt.AlignmentFlag.AlignVCenter)
self.label_2.setObjectName("label_2")
self.lblSourceDirectory = QtWidgets.QLabel(parent=Dialog)
self.lblSourceDirectory.setGeometry(QtCore.QRect(200, 15, 811, 24))
self.lblSourceDirectory.setObjectName("lblSourceDirectory")
self.lblDestinationDirectory = QtWidgets.QLabel(parent=Dialog)
self.lblDestinationDirectory.setGeometry(QtCore.QRect(200, 50, 811, 24))
self.lblDestinationDirectory.setObjectName("lblDestinationDirectory")
self.tableWidget = QtWidgets.QTableWidget(parent=Dialog)
self.tableWidget.setGeometry(QtCore.QRect(20, 90, 1001, 621))
self.tableWidget.setAlternatingRowColors(True)
self.tableWidget.setColumnCount(3)
self.tableWidget.setObjectName("tableWidget")
self.tableWidget.setRowCount(0)
self.retranslateUi(Dialog)
self.buttonBox.accepted.connect(Dialog.accept) # type: ignore
self.buttonBox.rejected.connect(Dialog.reject) # type: ignore
QtCore.QMetaObject.connectSlotsByName(Dialog)
def retranslateUi(self, Dialog):
_translate = QtCore.QCoreApplication.translate
Dialog.setWindowTitle(_translate("Dialog", "Dialog"))
self.label.setText(_translate("Dialog", "Source directory:"))
self.label_2.setText(_translate("Dialog", "Destination directory:"))
self.lblSourceDirectory.setText(_translate("Dialog", "lblSourceDirectory"))
self.lblDestinationDirectory.setText(_translate("Dialog", "lblDestinationDirectory"))

View File

@ -0,0 +1,70 @@
# -*- coding: utf-8 -*-
# Form implementation generated from reading ui file 'ui/dlg_SearchDatabase.ui'
#
# Created by: PyQt5 UI code generator 5.15.4
#
# WARNING: Any manual changes made to this file will be lost when pyuic5 is
# run again. Do not edit this file unless you know what you are doing.
from PyQt5 import QtCore, QtGui, QtWidgets
class Ui_Dialog(object):
def setupUi(self, Dialog):
Dialog.setObjectName("Dialog")
Dialog.resize(584, 377)
self.gridLayout_2 = QtWidgets.QGridLayout(Dialog)
self.gridLayout_2.setObjectName("gridLayout_2")
self.gridLayout = QtWidgets.QGridLayout()
self.gridLayout.setObjectName("gridLayout")
self.label = QtWidgets.QLabel(Dialog)
self.label.setObjectName("label")
self.gridLayout.addWidget(self.label, 0, 0, 1, 1)
self.searchString = QtWidgets.QLineEdit(Dialog)
self.searchString.setObjectName("searchString")
self.gridLayout.addWidget(self.searchString, 0, 1, 1, 1)
self.gridLayout_2.addLayout(self.gridLayout, 0, 0, 1, 1)
self.matchList = QtWidgets.QListWidget(Dialog)
self.matchList.setObjectName("matchList")
self.gridLayout_2.addWidget(self.matchList, 1, 0, 1, 1)
self.horizontalLayout = QtWidgets.QHBoxLayout()
self.horizontalLayout.setObjectName("horizontalLayout")
self.radioTitle = QtWidgets.QRadioButton(Dialog)
self.radioTitle.setChecked(True)
self.radioTitle.setObjectName("radioTitle")
self.horizontalLayout.addWidget(self.radioTitle)
self.radioArtist = QtWidgets.QRadioButton(Dialog)
self.radioArtist.setObjectName("radioArtist")
self.horizontalLayout.addWidget(self.radioArtist)
spacerItem = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum)
self.horizontalLayout.addItem(spacerItem)
self.btnAdd = QtWidgets.QPushButton(Dialog)
self.btnAdd.setDefault(True)
self.btnAdd.setObjectName("btnAdd")
self.horizontalLayout.addWidget(self.btnAdd)
self.btnAddClose = QtWidgets.QPushButton(Dialog)
self.btnAddClose.setObjectName("btnAddClose")
self.horizontalLayout.addWidget(self.btnAddClose)
self.btnClose = QtWidgets.QPushButton(Dialog)
self.btnClose.setObjectName("btnClose")
self.horizontalLayout.addWidget(self.btnClose)
self.gridLayout_2.addLayout(self.horizontalLayout, 2, 0, 1, 1)
self.dbPath = QtWidgets.QLabel(Dialog)
self.dbPath.setText("")
self.dbPath.setObjectName("dbPath")
self.gridLayout_2.addWidget(self.dbPath, 3, 0, 1, 1)
self.retranslateUi(Dialog)
QtCore.QMetaObject.connectSlotsByName(Dialog)
def retranslateUi(self, Dialog):
_translate = QtCore.QCoreApplication.translate
Dialog.setWindowTitle(_translate("Dialog", "Dialog"))
self.label.setText(_translate("Dialog", "Title:"))
self.radioTitle.setText(_translate("Dialog", "&Title"))
self.radioArtist.setText(_translate("Dialog", "&Artist"))
self.btnAdd.setText(_translate("Dialog", "&Add"))
self.btnAddClose.setText(_translate("Dialog", "A&dd and close"))
self.btnClose.setText(_translate("Dialog", "&Close"))

View File

@ -1,107 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>DateSelect</class>
<widget class="QDialog" name="DateSelect">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>280</width>
<height>166</height>
</rect>
</property>
<property name="windowTitle">
<string>Dialog</string>
</property>
<widget class="QDialogButtonBox" name="buttonBox">
<property name="geometry">
<rect>
<x>70</x>
<y>110</y>
<width>191</width>
<height>32</height>
</rect>
</property>
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="standardButtons">
<set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
</property>
</widget>
<widget class="QDateTimeEdit" name="dateTimeEdit">
<property name="geometry">
<rect>
<x>70</x>
<y>60</y>
<width>194</width>
<height>28</height>
</rect>
</property>
<property name="calendarPopup">
<bool>true</bool>
</property>
</widget>
<widget class="QLabel" name="label">
<property name="geometry">
<rect>
<x>10</x>
<y>20</y>
<width>261</width>
<height>19</height>
</rect>
</property>
<property name="text">
<string>Download CSV of tracks played</string>
</property>
</widget>
<widget class="QLabel" name="label_2">
<property name="geometry">
<rect>
<x>15</x>
<y>66</y>
<width>51</width>
<height>19</height>
</rect>
</property>
<property name="text">
<string>Since:</string>
</property>
</widget>
</widget>
<resources/>
<connections>
<connection>
<sender>buttonBox</sender>
<signal>accepted()</signal>
<receiver>DateSelect</receiver>
<slot>accept()</slot>
<hints>
<hint type="sourcelabel">
<x>248</x>
<y>254</y>
</hint>
<hint type="destinationlabel">
<x>157</x>
<y>274</y>
</hint>
</hints>
</connection>
<connection>
<sender>buttonBox</sender>
<signal>rejected()</signal>
<receiver>DateSelect</receiver>
<slot>reject()</slot>
<hints>
<hint type="sourcelabel">
<x>316</x>
<y>260</y>
</hint>
<hint type="destinationlabel">
<x>286</x>
<y>274</y>
</hint>
</hints>
</connection>
</connections>
</ui>

View File

@ -1,41 +0,0 @@
# Form implementation generated from reading ui file 'app/ui/downloadcsv.ui'
#
# Created by: PyQt6 UI code generator 6.5.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_DateSelect(object):
def setupUi(self, DateSelect):
DateSelect.setObjectName("DateSelect")
DateSelect.resize(280, 166)
self.buttonBox = QtWidgets.QDialogButtonBox(parent=DateSelect)
self.buttonBox.setGeometry(QtCore.QRect(70, 110, 191, 32))
self.buttonBox.setOrientation(QtCore.Qt.Orientation.Horizontal)
self.buttonBox.setStandardButtons(QtWidgets.QDialogButtonBox.StandardButton.Cancel|QtWidgets.QDialogButtonBox.StandardButton.Ok)
self.buttonBox.setObjectName("buttonBox")
self.dateTimeEdit = QtWidgets.QDateTimeEdit(parent=DateSelect)
self.dateTimeEdit.setGeometry(QtCore.QRect(70, 60, 194, 28))
self.dateTimeEdit.setCalendarPopup(True)
self.dateTimeEdit.setObjectName("dateTimeEdit")
self.label = QtWidgets.QLabel(parent=DateSelect)
self.label.setGeometry(QtCore.QRect(10, 20, 261, 19))
self.label.setObjectName("label")
self.label_2 = QtWidgets.QLabel(parent=DateSelect)
self.label_2.setGeometry(QtCore.QRect(15, 66, 51, 19))
self.label_2.setObjectName("label_2")
self.retranslateUi(DateSelect)
self.buttonBox.accepted.connect(DateSelect.accept) # type: ignore
self.buttonBox.rejected.connect(DateSelect.reject) # type: ignore
QtCore.QMetaObject.connectSlotsByName(DateSelect)
def retranslateUi(self, DateSelect):
_translate = QtCore.QCoreApplication.translate
DateSelect.setWindowTitle(_translate("DateSelect", "Dialog"))
self.label.setText(_translate("DateSelect", "Download CSV of tracks played"))
self.label_2.setText(_translate("DateSelect", "Since:"))

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

View File

@ -1,14 +1,5 @@
<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>
<file>record-button.png</file>
<file alias="headphones">headphone-symbol.png</file>
<file alias="musicmuster">musicmuster.png</file>
<file alias="stopsign">stopsign.png</file>
<file alias="wikipedia">wikipedia-logo.png</file>
<file alias="songsearch">songsearch_icon.png</file>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@ -1,178 +0,0 @@
# Form implementation generated from reading ui file 'app/ui/main_window_header.ui'
#
# Created by: PyQt6 UI code generator 6.8.1
#
# WARNING: Any manual changes made to this file will be lost when pyuic6 is
# run again. Do not edit this file unless you know what you are doing.
from PyQt6 import QtCore, QtGui, QtWidgets
class Ui_HeaderSection(object):
def setupUi(self, HeaderSection):
HeaderSection.setObjectName("HeaderSection")
HeaderSection.resize(1273, 179)
self.horizontalLayout = QtWidgets.QHBoxLayout(HeaderSection)
self.horizontalLayout.setObjectName("horizontalLayout")
self.gridLayout = QtWidgets.QGridLayout()
self.gridLayout.setObjectName("gridLayout")
self.horizontalLayout_3 = QtWidgets.QHBoxLayout()
self.horizontalLayout_3.setObjectName("horizontalLayout_3")
self.verticalLayout_3 = QtWidgets.QVBoxLayout()
self.verticalLayout_3.setObjectName("verticalLayout_3")
self.previous_track_2 = QtWidgets.QLabel(parent=HeaderSection)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Preferred, QtWidgets.QSizePolicy.Policy.Preferred)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.previous_track_2.sizePolicy().hasHeightForWidth())
self.previous_track_2.setSizePolicy(sizePolicy)
self.previous_track_2.setMaximumSize(QtCore.QSize(230, 16777215))
font = QtGui.QFont()
font.setFamily("Sans")
font.setPointSize(20)
self.previous_track_2.setFont(font)
self.previous_track_2.setStyleSheet("background-color: #f8d7da;\n"
"border: 1px solid rgb(85, 87, 83);")
self.previous_track_2.setAlignment(QtCore.Qt.AlignmentFlag.AlignRight|QtCore.Qt.AlignmentFlag.AlignTrailing|QtCore.Qt.AlignmentFlag.AlignVCenter)
self.previous_track_2.setObjectName("previous_track_2")
self.verticalLayout_3.addWidget(self.previous_track_2)
self.current_track_2 = QtWidgets.QLabel(parent=HeaderSection)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Preferred, QtWidgets.QSizePolicy.Policy.Preferred)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.current_track_2.sizePolicy().hasHeightForWidth())
self.current_track_2.setSizePolicy(sizePolicy)
self.current_track_2.setMaximumSize(QtCore.QSize(230, 16777215))
font = QtGui.QFont()
font.setFamily("Sans")
font.setPointSize(20)
self.current_track_2.setFont(font)
self.current_track_2.setStyleSheet("background-color: #d4edda;\n"
"border: 1px solid rgb(85, 87, 83);")
self.current_track_2.setAlignment(QtCore.Qt.AlignmentFlag.AlignRight|QtCore.Qt.AlignmentFlag.AlignTrailing|QtCore.Qt.AlignmentFlag.AlignVCenter)
self.current_track_2.setObjectName("current_track_2")
self.verticalLayout_3.addWidget(self.current_track_2)
self.next_track_2 = QtWidgets.QLabel(parent=HeaderSection)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Preferred, QtWidgets.QSizePolicy.Policy.Preferred)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.next_track_2.sizePolicy().hasHeightForWidth())
self.next_track_2.setSizePolicy(sizePolicy)
self.next_track_2.setMaximumSize(QtCore.QSize(230, 16777215))
font = QtGui.QFont()
font.setFamily("Sans")
font.setPointSize(20)
self.next_track_2.setFont(font)
self.next_track_2.setStyleSheet("background-color: #fff3cd;\n"
"border: 1px solid rgb(85, 87, 83);")
self.next_track_2.setAlignment(QtCore.Qt.AlignmentFlag.AlignRight|QtCore.Qt.AlignmentFlag.AlignTrailing|QtCore.Qt.AlignmentFlag.AlignVCenter)
self.next_track_2.setObjectName("next_track_2")
self.verticalLayout_3.addWidget(self.next_track_2)
self.horizontalLayout_3.addLayout(self.verticalLayout_3)
self.verticalLayout = QtWidgets.QVBoxLayout()
self.verticalLayout.setObjectName("verticalLayout")
self.hdrPreviousTrack = QtWidgets.QLabel(parent=HeaderSection)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Preferred, QtWidgets.QSizePolicy.Policy.Preferred)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.hdrPreviousTrack.sizePolicy().hasHeightForWidth())
self.hdrPreviousTrack.setSizePolicy(sizePolicy)
self.hdrPreviousTrack.setMinimumSize(QtCore.QSize(0, 0))
self.hdrPreviousTrack.setMaximumSize(QtCore.QSize(16777215, 16777215))
font = QtGui.QFont()
font.setFamily("Sans")
font.setPointSize(20)
self.hdrPreviousTrack.setFont(font)
self.hdrPreviousTrack.setStyleSheet("background-color: #f8d7da;\n"
"border: 1px solid rgb(85, 87, 83);")
self.hdrPreviousTrack.setText("")
self.hdrPreviousTrack.setWordWrap(False)
self.hdrPreviousTrack.setObjectName("hdrPreviousTrack")
self.verticalLayout.addWidget(self.hdrPreviousTrack)
self.hdrCurrentTrack = QtWidgets.QPushButton(parent=HeaderSection)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Preferred, QtWidgets.QSizePolicy.Policy.Preferred)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.hdrCurrentTrack.sizePolicy().hasHeightForWidth())
self.hdrCurrentTrack.setSizePolicy(sizePolicy)
font = QtGui.QFont()
font.setPointSize(20)
self.hdrCurrentTrack.setFont(font)
self.hdrCurrentTrack.setStyleSheet("background-color: #d4edda;\n"
"border: 1px solid rgb(85, 87, 83);\n"
"text-align: left;\n"
"padding-left: 8px;\n"
"")
self.hdrCurrentTrack.setText("")
self.hdrCurrentTrack.setFlat(True)
self.hdrCurrentTrack.setObjectName("hdrCurrentTrack")
self.verticalLayout.addWidget(self.hdrCurrentTrack)
self.hdrNextTrack = QtWidgets.QPushButton(parent=HeaderSection)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Preferred, QtWidgets.QSizePolicy.Policy.Preferred)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.hdrNextTrack.sizePolicy().hasHeightForWidth())
self.hdrNextTrack.setSizePolicy(sizePolicy)
font = QtGui.QFont()
font.setPointSize(20)
self.hdrNextTrack.setFont(font)
self.hdrNextTrack.setStyleSheet("background-color: #fff3cd;\n"
"border: 1px solid rgb(85, 87, 83);\n"
"text-align: left;\n"
"padding-left: 8px;")
self.hdrNextTrack.setText("")
self.hdrNextTrack.setFlat(True)
self.hdrNextTrack.setObjectName("hdrNextTrack")
self.verticalLayout.addWidget(self.hdrNextTrack)
self.horizontalLayout_3.addLayout(self.verticalLayout)
self.frame_2 = QtWidgets.QFrame(parent=HeaderSection)
self.frame_2.setMinimumSize(QtCore.QSize(0, 131))
self.frame_2.setMaximumSize(QtCore.QSize(230, 131))
self.frame_2.setFrameShape(QtWidgets.QFrame.Shape.StyledPanel)
self.frame_2.setFrameShadow(QtWidgets.QFrame.Shadow.Raised)
self.frame_2.setObjectName("frame_2")
self.verticalLayout_10 = QtWidgets.QVBoxLayout(self.frame_2)
self.verticalLayout_10.setObjectName("verticalLayout_10")
self.lblTOD = QtWidgets.QLabel(parent=self.frame_2)
self.lblTOD.setMinimumSize(QtCore.QSize(208, 0))
font = QtGui.QFont()
font.setPointSize(35)
self.lblTOD.setFont(font)
self.lblTOD.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
self.lblTOD.setObjectName("lblTOD")
self.verticalLayout_10.addWidget(self.lblTOD)
self.label_elapsed_timer = QtWidgets.QLabel(parent=self.frame_2)
font = QtGui.QFont()
font.setFamily("FreeSans")
font.setPointSize(18)
font.setBold(False)
font.setWeight(50)
self.label_elapsed_timer.setFont(font)
self.label_elapsed_timer.setStyleSheet("color: black;")
self.label_elapsed_timer.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
self.label_elapsed_timer.setObjectName("label_elapsed_timer")
self.verticalLayout_10.addWidget(self.label_elapsed_timer)
self.horizontalLayout_3.addWidget(self.frame_2)
self.gridLayout.addLayout(self.horizontalLayout_3, 0, 0, 1, 1)
self.frame_4 = QtWidgets.QFrame(parent=HeaderSection)
self.frame_4.setMinimumSize(QtCore.QSize(0, 16))
self.frame_4.setAutoFillBackground(False)
self.frame_4.setStyleSheet("background-color: rgb(154, 153, 150)")
self.frame_4.setFrameShape(QtWidgets.QFrame.Shape.StyledPanel)
self.frame_4.setFrameShadow(QtWidgets.QFrame.Shadow.Raised)
self.frame_4.setObjectName("frame_4")
self.gridLayout.addWidget(self.frame_4, 1, 0, 1, 1)
self.horizontalLayout.addLayout(self.gridLayout)
self.retranslateUi(HeaderSection)
QtCore.QMetaObject.connectSlotsByName(HeaderSection)
def retranslateUi(self, HeaderSection):
_translate = QtCore.QCoreApplication.translate
HeaderSection.setWindowTitle(_translate("HeaderSection", "Form"))
self.previous_track_2.setText(_translate("HeaderSection", "Last track:"))
self.current_track_2.setText(_translate("HeaderSection", "Current track:"))
self.next_track_2.setText(_translate("HeaderSection", "Next track:"))
self.lblTOD.setText(_translate("HeaderSection", "00:00:00"))
self.label_elapsed_timer.setText(_translate("HeaderSection", "00:00 / 00:00"))

View File

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

View File

@ -1,34 +0,0 @@
# Form implementation generated from reading ui file 'app/ui/main_window_playlist.ui'
#
# Created by: PyQt6 UI code generator 6.8.1
#
# WARNING: Any manual changes made to this file will be lost when pyuic6 is
# run again. Do not edit this file unless you know what you are doing.
from PyQt6 import QtCore, QtGui, QtWidgets
class Ui_PlaylistSection(object):
def setupUi(self, PlaylistSection):
PlaylistSection.setObjectName("PlaylistSection")
PlaylistSection.resize(1249, 499)
self.horizontalLayout = QtWidgets.QHBoxLayout(PlaylistSection)
self.horizontalLayout.setObjectName("horizontalLayout")
self.splitter = QtWidgets.QSplitter(parent=PlaylistSection)
self.splitter.setOrientation(QtCore.Qt.Orientation.Vertical)
self.splitter.setObjectName("splitter")
self.tabPlaylist = QtWidgets.QTabWidget(parent=self.splitter)
self.tabPlaylist.setDocumentMode(False)
self.tabPlaylist.setTabsClosable(True)
self.tabPlaylist.setMovable(True)
self.tabPlaylist.setObjectName("tabPlaylist")
self.horizontalLayout.addWidget(self.splitter)
self.retranslateUi(PlaylistSection)
self.tabPlaylist.setCurrentIndex(-1)
QtCore.QMetaObject.connectSlotsByName(PlaylistSection)
def retranslateUi(self, PlaylistSection):
_translate = QtCore.QCoreApplication.translate
PlaylistSection.setWindowTitle(_translate("PlaylistSection", "Form"))

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

@ -0,0 +1,554 @@
# -*- coding: utf-8 -*-
# Form implementation generated from reading ui file 'ui/main_window.ui'
#
# Created by: PyQt5 UI code generator 5.15.4
#
# WARNING: Any manual changes made to this file will be lost when pyuic5 is
# run again. Do not edit this file unless you know what you are doing.
from PyQt5 import QtCore, QtGui, QtWidgets
class Ui_MainWindow(object):
def setupUi(self, MainWindow):
MainWindow.setObjectName("MainWindow")
MainWindow.resize(1164, 857)
MainWindow.setStyleSheet("")
self.centralwidget = QtWidgets.QWidget(MainWindow)
self.centralwidget.setObjectName("centralwidget")
self.gridLayout_3 = QtWidgets.QGridLayout(self.centralwidget)
self.gridLayout_3.setObjectName("gridLayout_3")
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(self.centralwidget)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.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.AlignRight|QtCore.Qt.AlignTrailing|QtCore.Qt.AlignVCenter)
self.previous_track_2.setObjectName("previous_track_2")
self.verticalLayout_3.addWidget(self.previous_track_2)
self.current_track_2 = QtWidgets.QLabel(self.centralwidget)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.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.AlignRight|QtCore.Qt.AlignTrailing|QtCore.Qt.AlignVCenter)
self.current_track_2.setObjectName("current_track_2")
self.verticalLayout_3.addWidget(self.current_track_2)
self.next_track_2 = QtWidgets.QLabel(self.centralwidget)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.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.AlignRight|QtCore.Qt.AlignTrailing|QtCore.Qt.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(self.centralwidget)
self.hdrPreviousTrack.setMinimumSize(QtCore.QSize(16, 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(True)
self.hdrPreviousTrack.setObjectName("hdrPreviousTrack")
self.verticalLayout.addWidget(self.hdrPreviousTrack)
self.hdrCurrentTrack = QtWidgets.QLabel(self.centralwidget)
font = QtGui.QFont()
font.setFamily("Sans")
font.setPointSize(20)
self.hdrCurrentTrack.setFont(font)
self.hdrCurrentTrack.setStyleSheet("background-color: #d4edda;\n"
"border: 1px solid rgb(85, 87, 83);")
self.hdrCurrentTrack.setText("")
self.hdrCurrentTrack.setWordWrap(True)
self.hdrCurrentTrack.setObjectName("hdrCurrentTrack")
self.verticalLayout.addWidget(self.hdrCurrentTrack)
self.hdrNextTrack = ElideLabel(self.centralwidget)
self.hdrNextTrack.setMinimumSize(QtCore.QSize(0, 39))
self.hdrNextTrack.setMaximumSize(QtCore.QSize(16777215, 39))
font = QtGui.QFont()
font.setFamily("Sans")
font.setPointSize(20)
self.hdrNextTrack.setFont(font)
self.hdrNextTrack.setStyleSheet("background-color: #fff3cd;\n"
"border: 1px solid rgb(85, 87, 83);")
self.hdrNextTrack.setText("")
self.hdrNextTrack.setWordWrap(True)
self.hdrNextTrack.setObjectName("hdrNextTrack")
self.verticalLayout.addWidget(self.hdrNextTrack)
self.horizontalLayout_3.addLayout(self.verticalLayout)
self.frame_2 = QtWidgets.QFrame(self.centralwidget)
self.frame_2.setMaximumSize(QtCore.QSize(230, 16777215))
self.frame_2.setFrameShape(QtWidgets.QFrame.StyledPanel)
self.frame_2.setFrameShadow(QtWidgets.QFrame.Raised)
self.frame_2.setObjectName("frame_2")
self.gridLayout_2 = QtWidgets.QGridLayout(self.frame_2)
self.gridLayout_2.setObjectName("gridLayout_2")
self.lblTOD = QtWidgets.QLabel(self.frame_2)
self.lblTOD.setMinimumSize(QtCore.QSize(208, 109))
font = QtGui.QFont()
font.setPointSize(35)
self.lblTOD.setFont(font)
self.lblTOD.setAlignment(QtCore.Qt.AlignCenter)
self.lblTOD.setObjectName("lblTOD")
self.gridLayout_2.addWidget(self.lblTOD, 0, 0, 1, 1)
self.horizontalLayout_3.addWidget(self.frame_2)
self.gridLayout_3.addLayout(self.horizontalLayout_3, 0, 0, 1, 1)
self.frame_5 = QtWidgets.QFrame(self.centralwidget)
self.frame_5.setFrameShape(QtWidgets.QFrame.StyledPanel)
self.frame_5.setFrameShadow(QtWidgets.QFrame.Raised)
self.frame_5.setObjectName("frame_5")
self.horizontalLayout = QtWidgets.QHBoxLayout(self.frame_5)
self.horizontalLayout.setObjectName("horizontalLayout")
self.btnPlay = QtWidgets.QPushButton(self.frame_5)
icon = QtGui.QIcon()
icon.addPixmap(QtGui.QPixmap(":/icons/play"), QtGui.QIcon.Normal, QtGui.QIcon.Off)
self.btnPlay.setIcon(icon)
self.btnPlay.setIconSize(QtCore.QSize(30, 30))
self.btnPlay.setObjectName("btnPlay")
self.horizontalLayout.addWidget(self.btnPlay)
spacerItem = QtWidgets.QSpacerItem(68, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum)
self.horizontalLayout.addItem(spacerItem)
self.btnSongInfo = QtWidgets.QPushButton(self.frame_5)
icon1 = QtGui.QIcon()
icon1.addPixmap(QtGui.QPixmap(":/icons/wikipedia"), QtGui.QIcon.Normal, QtGui.QIcon.Off)
self.btnSongInfo.setIcon(icon1)
self.btnSongInfo.setIconSize(QtCore.QSize(30, 30))
self.btnSongInfo.setObjectName("btnSongInfo")
self.horizontalLayout.addWidget(self.btnSongInfo)
spacerItem1 = QtWidgets.QSpacerItem(70, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum)
self.horizontalLayout.addItem(spacerItem1)
self.btnDatabase = QtWidgets.QPushButton(self.frame_5)
icon2 = QtGui.QIcon()
icon2.addPixmap(QtGui.QPixmap(":/icons/search_db"), QtGui.QIcon.Normal, QtGui.QIcon.Off)
self.btnDatabase.setIcon(icon2)
self.btnDatabase.setIconSize(QtCore.QSize(30, 30))
self.btnDatabase.setObjectName("btnDatabase")
self.horizontalLayout.addWidget(self.btnDatabase)
self.btnAddFile = QtWidgets.QPushButton(self.frame_5)
icon3 = QtGui.QIcon()
icon3.addPixmap(QtGui.QPixmap(":/icons/open_file"), QtGui.QIcon.Normal, QtGui.QIcon.Off)
self.btnAddFile.setIcon(icon3)
self.btnAddFile.setIconSize(QtCore.QSize(30, 30))
self.btnAddFile.setObjectName("btnAddFile")
self.horizontalLayout.addWidget(self.btnAddFile)
spacerItem2 = QtWidgets.QSpacerItem(68, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum)
self.horizontalLayout.addItem(spacerItem2)
self.btnAddNote = QtWidgets.QPushButton(self.frame_5)
icon4 = QtGui.QIcon()
icon4.addPixmap(QtGui.QPixmap(":/icons/note"), QtGui.QIcon.Normal, QtGui.QIcon.Off)
self.btnAddNote.setIcon(icon4)
self.btnAddNote.setIconSize(QtCore.QSize(30, 30))
self.btnAddNote.setObjectName("btnAddNote")
self.horizontalLayout.addWidget(self.btnAddNote)
self.btnSetNext = QtWidgets.QPushButton(self.frame_5)
icon5 = QtGui.QIcon()
icon5.addPixmap(QtGui.QPixmap(":/icons/set_next"), QtGui.QIcon.Normal, QtGui.QIcon.Off)
self.btnSetNext.setIcon(icon5)
self.btnSetNext.setIconSize(QtCore.QSize(30, 30))
self.btnSetNext.setObjectName("btnSetNext")
self.horizontalLayout.addWidget(self.btnSetNext)
spacerItem3 = QtWidgets.QSpacerItem(69, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum)
self.horizontalLayout.addItem(spacerItem3)
self.btnStop = QtWidgets.QPushButton(self.frame_5)
icon6 = QtGui.QIcon()
icon6.addPixmap(QtGui.QPixmap(":/icons/stopsign"), QtGui.QIcon.Normal, QtGui.QIcon.Off)
self.btnStop.setIcon(icon6)
self.btnStop.setIconSize(QtCore.QSize(30, 30))
self.btnStop.setObjectName("btnStop")
self.horizontalLayout.addWidget(self.btnStop)
self.btnFade = QtWidgets.QPushButton(self.frame_5)
icon7 = QtGui.QIcon()
icon7.addPixmap(QtGui.QPixmap(":/icons/fade"), QtGui.QIcon.Normal, QtGui.QIcon.Off)
self.btnFade.setIcon(icon7)
self.btnFade.setIconSize(QtCore.QSize(30, 30))
self.btnFade.setObjectName("btnFade")
self.horizontalLayout.addWidget(self.btnFade)
self.spnVolume = QtWidgets.QSpinBox(self.frame_5)
self.spnVolume.setMaximum(100)
self.spnVolume.setProperty("value", 100)
self.spnVolume.setObjectName("spnVolume")
self.horizontalLayout.addWidget(self.spnVolume)
self.gridLayout_3.addWidget(self.frame_5, 1, 0, 1, 1)
self.tabPlaylist = QtWidgets.QTabWidget(self.centralwidget)
self.tabPlaylist.setDocumentMode(False)
self.tabPlaylist.setTabsClosable(True)
self.tabPlaylist.setMovable(True)
self.tabPlaylist.setObjectName("tabPlaylist")
self.gridLayout_3.addWidget(self.tabPlaylist, 2, 0, 1, 1)
self.horizontalLayout_2 = QtWidgets.QHBoxLayout()
self.horizontalLayout_2.setObjectName("horizontalLayout_2")
self.frame = QtWidgets.QFrame(self.centralwidget)
self.frame.setFrameShape(QtWidgets.QFrame.StyledPanel)
self.frame.setFrameShadow(QtWidgets.QFrame.Raised)
self.frame.setObjectName("frame")
self.gridLayout = QtWidgets.QGridLayout(self.frame)
self.gridLayout.setObjectName("gridLayout")
self.label_2 = QtWidgets.QLabel(self.frame)
self.label_2.setAlignment(QtCore.Qt.AlignRight|QtCore.Qt.AlignTrailing|QtCore.Qt.AlignVCenter)
self.label_2.setObjectName("label_2")
self.gridLayout.addWidget(self.label_2, 0, 0, 1, 1)
self.label_start_tod = QtWidgets.QLabel(self.frame)
font = QtGui.QFont()
font.setFamily("FreeSans")
font.setPointSize(16)
self.label_start_tod.setFont(font)
self.label_start_tod.setScaledContents(False)
self.label_start_tod.setObjectName("label_start_tod")
self.gridLayout.addWidget(self.label_start_tod, 0, 1, 1, 1)
self.label_3 = QtWidgets.QLabel(self.frame)
self.label_3.setAlignment(QtCore.Qt.AlignRight|QtCore.Qt.AlignTrailing|QtCore.Qt.AlignVCenter)
self.label_3.setObjectName("label_3")
self.gridLayout.addWidget(self.label_3, 1, 0, 1, 1)
self.label_silent_tod = QtWidgets.QLabel(self.frame)
font = QtGui.QFont()
font.setFamily("FreeSans")
font.setPointSize(16)
self.label_silent_tod.setFont(font)
self.label_silent_tod.setScaledContents(False)
self.label_silent_tod.setObjectName("label_silent_tod")
self.gridLayout.addWidget(self.label_silent_tod, 1, 1, 1, 1)
self.label_7 = QtWidgets.QLabel(self.frame)
self.label_7.setAlignment(QtCore.Qt.AlignRight|QtCore.Qt.AlignTrailing|QtCore.Qt.AlignVCenter)
self.label_7.setObjectName("label_7")
self.gridLayout.addWidget(self.label_7, 2, 0, 1, 1)
self.label_fade_length = QtWidgets.QLabel(self.frame)
font = QtGui.QFont()
font.setFamily("FreeSans")
font.setPointSize(16)
self.label_fade_length.setFont(font)
self.label_fade_length.setScaledContents(False)
self.label_fade_length.setObjectName("label_fade_length")
self.gridLayout.addWidget(self.label_fade_length, 2, 1, 1, 1)
self.horizontalLayout_2.addWidget(self.frame)
self.frame_elapsed = QtWidgets.QFrame(self.centralwidget)
self.frame_elapsed.setStyleSheet("")
self.frame_elapsed.setFrameShape(QtWidgets.QFrame.StyledPanel)
self.frame_elapsed.setFrameShadow(QtWidgets.QFrame.Raised)
self.frame_elapsed.setObjectName("frame_elapsed")
self.verticalLayout_5 = QtWidgets.QVBoxLayout(self.frame_elapsed)
self.verticalLayout_5.setObjectName("verticalLayout_5")
self.label = QtWidgets.QLabel(self.frame_elapsed)
self.label.setAlignment(QtCore.Qt.AlignCenter)
self.label.setObjectName("label")
self.verticalLayout_5.addWidget(self.label)
self.label_elapsed_timer = QtWidgets.QLabel(self.frame_elapsed)
font = QtGui.QFont()
font.setFamily("FreeSans")
font.setPointSize(40)
font.setBold(False)
font.setWeight(50)
self.label_elapsed_timer.setFont(font)
self.label_elapsed_timer.setAlignment(QtCore.Qt.AlignCenter)
self.label_elapsed_timer.setObjectName("label_elapsed_timer")
self.verticalLayout_5.addWidget(self.label_elapsed_timer)
self.horizontalLayout_2.addWidget(self.frame_elapsed)
self.frame_fade = QtWidgets.QFrame(self.centralwidget)
self.frame_fade.setStyleSheet("")
self.frame_fade.setFrameShape(QtWidgets.QFrame.StyledPanel)
self.frame_fade.setFrameShadow(QtWidgets.QFrame.Raised)
self.frame_fade.setObjectName("frame_fade")
self.verticalLayout_6 = QtWidgets.QVBoxLayout(self.frame_fade)
self.verticalLayout_6.setObjectName("verticalLayout_6")
self.label_4 = QtWidgets.QLabel(self.frame_fade)
self.label_4.setAlignment(QtCore.Qt.AlignCenter)
self.label_4.setObjectName("label_4")
self.verticalLayout_6.addWidget(self.label_4)
self.label_fade_timer = QtWidgets.QLabel(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.AlignCenter)
self.label_fade_timer.setObjectName("label_fade_timer")
self.verticalLayout_6.addWidget(self.label_fade_timer)
self.horizontalLayout_2.addWidget(self.frame_fade)
self.frame_silent = QtWidgets.QFrame(self.centralwidget)
self.frame_silent.setStyleSheet("")
self.frame_silent.setFrameShape(QtWidgets.QFrame.StyledPanel)
self.frame_silent.setFrameShadow(QtWidgets.QFrame.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(self.frame_silent)
self.label_5.setAlignment(QtCore.Qt.AlignCenter)
self.label_5.setObjectName("label_5")
self.verticalLayout_7.addWidget(self.label_5)
self.label_silent_timer = QtWidgets.QLabel(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.AlignCenter)
self.label_silent_timer.setObjectName("label_silent_timer")
self.verticalLayout_7.addWidget(self.label_silent_timer)
self.horizontalLayout_2.addWidget(self.frame_silent)
self.frame_end = QtWidgets.QFrame(self.centralwidget)
self.frame_end.setStyleSheet("")
self.frame_end.setFrameShape(QtWidgets.QFrame.StyledPanel)
self.frame_end.setFrameShadow(QtWidgets.QFrame.Raised)
self.frame_end.setObjectName("frame_end")
self.verticalLayout_8 = QtWidgets.QVBoxLayout(self.frame_end)
self.verticalLayout_8.setObjectName("verticalLayout_8")
self.label_6 = QtWidgets.QLabel(self.frame_end)
self.label_6.setAlignment(QtCore.Qt.AlignCenter)
self.label_6.setObjectName("label_6")
self.verticalLayout_8.addWidget(self.label_6)
self.label_end_timer = QtWidgets.QLabel(self.frame_end)
font = QtGui.QFont()
font.setFamily("FreeSans")
font.setPointSize(40)
font.setBold(False)
font.setWeight(50)
self.label_end_timer.setFont(font)
self.label_end_timer.setAlignment(QtCore.Qt.AlignCenter)
self.label_end_timer.setObjectName("label_end_timer")
self.verticalLayout_8.addWidget(self.label_end_timer)
self.horizontalLayout_2.addWidget(self.frame_end)
self.gridLayout_3.addLayout(self.horizontalLayout_2, 3, 0, 1, 1)
MainWindow.setCentralWidget(self.centralwidget)
self.menubar = QtWidgets.QMenuBar(MainWindow)
self.menubar.setGeometry(QtCore.QRect(0, 0, 1164, 29))
self.menubar.setObjectName("menubar")
self.menuFile = QtWidgets.QMenu(self.menubar)
self.menuFile.setObjectName("menuFile")
self.menuPlaylist = QtWidgets.QMenu(self.menubar)
self.menuPlaylist.setObjectName("menuPlaylist")
self.menu_Tracks = QtWidgets.QMenu(self.menubar)
self.menu_Tracks.setObjectName("menu_Tracks")
self.menuTest = QtWidgets.QMenu(self.menubar)
self.menuTest.setObjectName("menuTest")
MainWindow.setMenuBar(self.menubar)
self.statusbar = QtWidgets.QStatusBar(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 = QtWidgets.QAction(MainWindow)
icon8 = QtGui.QIcon()
icon8.addPixmap(QtGui.QPixmap("ui/../../../../.designer/backup/icon-play.png"), QtGui.QIcon.Normal, QtGui.QIcon.Off)
self.actionPlay_next.setIcon(icon8)
self.actionPlay_next.setObjectName("actionPlay_next")
self.actionSkip_next = QtWidgets.QAction(MainWindow)
icon9 = QtGui.QIcon()
icon9.addPixmap(QtGui.QPixmap(":/icons/next"), QtGui.QIcon.Normal, QtGui.QIcon.Off)
self.actionSkip_next.setIcon(icon9)
self.actionSkip_next.setObjectName("actionSkip_next")
self.actionSearch_database = QtWidgets.QAction(MainWindow)
icon10 = QtGui.QIcon()
icon10.addPixmap(QtGui.QPixmap("ui/../../../../.designer/backup/icon_search_database.png"), QtGui.QIcon.Normal, QtGui.QIcon.Off)
self.actionSearch_database.setIcon(icon10)
self.actionSearch_database.setObjectName("actionSearch_database")
self.actionAdd_file = QtWidgets.QAction(MainWindow)
icon11 = QtGui.QIcon()
icon11.addPixmap(QtGui.QPixmap("ui/../../../../.designer/backup/icon_open_file.png"), QtGui.QIcon.Normal, QtGui.QIcon.Off)
self.actionAdd_file.setIcon(icon11)
self.actionAdd_file.setObjectName("actionAdd_file")
self.actionFade = QtWidgets.QAction(MainWindow)
icon12 = QtGui.QIcon()
icon12.addPixmap(QtGui.QPixmap("ui/../../../../.designer/backup/icon-fade.png"), QtGui.QIcon.Normal, QtGui.QIcon.Off)
self.actionFade.setIcon(icon12)
self.actionFade.setObjectName("actionFade")
self.actionStop = QtWidgets.QAction(MainWindow)
icon13 = QtGui.QIcon()
icon13.addPixmap(QtGui.QPixmap(":/icons/stop"), QtGui.QIcon.Normal, QtGui.QIcon.Off)
self.actionStop.setIcon(icon13)
self.actionStop.setObjectName("actionStop")
self.action_Clear_selection = QtWidgets.QAction(MainWindow)
self.action_Clear_selection.setObjectName("action_Clear_selection")
self.action_Resume_previous = QtWidgets.QAction(MainWindow)
icon14 = QtGui.QIcon()
icon14.addPixmap(QtGui.QPixmap(":/icons/previous"), QtGui.QIcon.Normal, QtGui.QIcon.Off)
self.action_Resume_previous.setIcon(icon14)
self.action_Resume_previous.setObjectName("action_Resume_previous")
self.actionE_xit = QtWidgets.QAction(MainWindow)
self.actionE_xit.setObjectName("actionE_xit")
self.actionTest = QtWidgets.QAction(MainWindow)
self.actionTest.setObjectName("actionTest")
self.actionOpenPlaylist = QtWidgets.QAction(MainWindow)
self.actionOpenPlaylist.setObjectName("actionOpenPlaylist")
self.actionNewPlaylist = QtWidgets.QAction(MainWindow)
self.actionNewPlaylist.setObjectName("actionNewPlaylist")
self.actionTestFunction = QtWidgets.QAction(MainWindow)
self.actionTestFunction.setObjectName("actionTestFunction")
self.actionSkipToFade = QtWidgets.QAction(MainWindow)
self.actionSkipToFade.setObjectName("actionSkipToFade")
self.actionSkipToEnd = QtWidgets.QAction(MainWindow)
self.actionSkipToEnd.setObjectName("actionSkipToEnd")
self.actionClosePlaylist = QtWidgets.QAction(MainWindow)
self.actionClosePlaylist.setEnabled(True)
self.actionClosePlaylist.setObjectName("actionClosePlaylist")
self.actionRenamePlaylist = QtWidgets.QAction(MainWindow)
self.actionRenamePlaylist.setEnabled(False)
self.actionRenamePlaylist.setObjectName("actionRenamePlaylist")
self.actionDeletePlaylist = QtWidgets.QAction(MainWindow)
self.actionDeletePlaylist.setEnabled(False)
self.actionDeletePlaylist.setObjectName("actionDeletePlaylist")
self.actionMoveSelected = QtWidgets.QAction(MainWindow)
self.actionMoveSelected.setObjectName("actionMoveSelected")
self.actionExport_playlist = QtWidgets.QAction(MainWindow)
self.actionExport_playlist.setObjectName("actionExport_playlist")
self.actionSetNext = QtWidgets.QAction(MainWindow)
self.actionSetNext.setObjectName("actionSetNext")
self.actionSelect_next_track = QtWidgets.QAction(MainWindow)
self.actionSelect_next_track.setObjectName("actionSelect_next_track")
self.actionSelect_previous_track = QtWidgets.QAction(MainWindow)
self.actionSelect_previous_track.setObjectName("actionSelect_previous_track")
self.actionSelect_played_tracks = QtWidgets.QAction(MainWindow)
self.actionSelect_played_tracks.setObjectName("actionSelect_played_tracks")
self.actionSelect_unplayed_tracks = QtWidgets.QAction(MainWindow)
self.actionSelect_unplayed_tracks.setObjectName("actionSelect_unplayed_tracks")
self.actionAdd_note = QtWidgets.QAction(MainWindow)
self.actionAdd_note.setObjectName("actionAdd_note")
self.menuFile.addAction(self.actionE_xit)
self.menuPlaylist.addAction(self.actionNewPlaylist)
self.menuPlaylist.addAction(self.actionOpenPlaylist)
self.menuPlaylist.addAction(self.actionClosePlaylist)
self.menuPlaylist.addAction(self.actionRenamePlaylist)
self.menuPlaylist.addAction(self.actionDeletePlaylist)
self.menuPlaylist.addSeparator()
self.menuPlaylist.addAction(self.actionSearch_database)
self.menuPlaylist.addAction(self.actionAdd_file)
self.menuPlaylist.addAction(self.actionAdd_note)
self.menuPlaylist.addAction(self.action_Clear_selection)
self.menuPlaylist.addSeparator()
self.menuPlaylist.addAction(self.actionSelect_unplayed_tracks)
self.menuPlaylist.addAction(self.actionSelect_played_tracks)
self.menuPlaylist.addAction(self.actionMoveSelected)
self.menuPlaylist.addSeparator()
self.menuPlaylist.addAction(self.actionExport_playlist)
self.menuPlaylist.addSeparator()
self.menuPlaylist.addAction(self.actionSelect_next_track)
self.menuPlaylist.addAction(self.actionSelect_previous_track)
self.menu_Tracks.addAction(self.actionPlay_next)
self.menu_Tracks.addAction(self.actionSkip_next)
self.menu_Tracks.addAction(self.actionFade)
self.menu_Tracks.addAction(self.actionStop)
self.menu_Tracks.addAction(self.action_Resume_previous)
self.menu_Tracks.addSeparator()
self.menu_Tracks.addAction(self.actionSetNext)
self.menuTest.addAction(self.actionTestFunction)
self.menuTest.addSeparator()
self.menuTest.addAction(self.actionSkipToFade)
self.menuTest.addAction(self.actionSkipToEnd)
self.menubar.addAction(self.menuFile.menuAction())
self.menubar.addAction(self.menuPlaylist.menuAction())
self.menubar.addAction(self.menu_Tracks.menuAction())
self.menubar.addAction(self.menuTest.menuAction())
self.retranslateUi(MainWindow)
self.tabPlaylist.setCurrentIndex(-1)
self.actionE_xit.triggered.connect(MainWindow.close)
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.btnPlay.setText(_translate("MainWindow", " Play"))
self.btnSongInfo.setText(_translate("MainWindow", "Song info"))
self.btnDatabase.setText(_translate("MainWindow", "Database"))
self.btnAddFile.setText(_translate("MainWindow", "Add file"))
self.btnAddNote.setText(_translate("MainWindow", "Add note"))
self.btnSetNext.setText(_translate("MainWindow", "Set next"))
self.btnStop.setText(_translate("MainWindow", "Stop"))
self.btnFade.setText(_translate("MainWindow", "Fade"))
self.label_2.setText(_translate("MainWindow", "Started at:"))
self.label_start_tod.setText(_translate("MainWindow", "00:00:00"))
self.label_3.setText(_translate("MainWindow", "Silent at:"))
self.label_silent_tod.setText(_translate("MainWindow", "00:00:00"))
self.label_7.setText(_translate("MainWindow", "Fade length:"))
self.label_fade_length.setText(_translate("MainWindow", "0:00"))
self.label.setText(_translate("MainWindow", "Elapsed time"))
self.label_elapsed_timer.setText(_translate("MainWindow", "00:00"))
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.label_6.setText(_translate("MainWindow", "End"))
self.label_end_timer.setText(_translate("MainWindow", "00:00"))
self.menuFile.setTitle(_translate("MainWindow", "Fi&le"))
self.menuPlaylist.setTitle(_translate("MainWindow", "Pla&ylist"))
self.menu_Tracks.setTitle(_translate("MainWindow", "&Tracks"))
self.menuTest.setTitle(_translate("MainWindow", "TestMo&de"))
self.actionPlay_next.setText(_translate("MainWindow", "&Play next"))
self.actionPlay_next.setShortcut(_translate("MainWindow", "Return"))
self.actionSkip_next.setText(_translate("MainWindow", "Skip to &next"))
self.actionSkip_next.setShortcut(_translate("MainWindow", "Ctrl+Alt+Return"))
self.actionSearch_database.setText(_translate("MainWindow", "Search &database"))
self.actionSearch_database.setShortcut(_translate("MainWindow", "Ctrl+D"))
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+S"))
self.actionStop.setText(_translate("MainWindow", "S&top"))
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 playlist..."))
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.actionSelect_unplayed_tracks.setText(_translate("MainWindow", "Select unplayed tracks"))
self.actionAdd_note.setText(_translate("MainWindow", "Add note..."))
self.actionAdd_note.setShortcut(_translate("MainWindow", "Ctrl+T"))
from musicmuster import ElideLabel
import icons_rc

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

10
app/ui/playlist_ui.py Normal file
View File

@ -0,0 +1,10 @@
# -*- coding: utf-8 -*-
# Form implementation generated from reading ui file 'ui/playlist.ui'
#
# Created by: PyQt5 UI code generator 5.15.4
#
# WARNING: Any manual changes made to this file will be lost when pyuic5 is
# run again. Do not edit this file unless you know what you are doing.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

View File

@ -1,97 +0,0 @@
#!/usr/bin/env python
# Standard library imports
import os
# PyQt imports
# Third party imports
from sqlalchemy.orm.session import Session
# App imports
from config import Config
from helpers import (
get_tags,
)
from log import log
from models import Tracks
def check_db(session: Session) -> None:
"""
Database consistency check.
A report is generated if issues are found, but there are no automatic
corrections made.
Search for tracks that are in the music directory but not the datebase
Check all paths in database exist
"""
db_paths = set([a.path for a in Tracks.get_all(session)])
os_paths_list = []
for root, _dirs, files in os.walk(Config.ROOT):
for f in files:
path = os.path.join(root, f)
ext = os.path.splitext(f)[1]
if ext in [".flac", ".mp3"]:
os_paths_list.append(path)
os_paths = set(os_paths_list)
# Find any files in music directory that are not in database
files_not_in_db = list(os_paths - db_paths)
# Find paths in database missing in music directory
paths_not_found = []
missing_file_count = 0
more_files_to_report = False
for path in list(db_paths - os_paths):
if missing_file_count >= Config.MAX_MISSING_FILES_TO_REPORT:
more_files_to_report = True
break
missing_file_count += 1
track = Tracks.get_by_path(session, path)
if not track:
# This shouldn't happen as we're looking for paths in
# database that aren't in filesystem, but just in case...
log.error(f"update_db: {path} not found in db")
continue
paths_not_found.append(track)
# Output messages (so if running via cron, these will get sent to
# user)
if files_not_in_db:
print("Files in music directory but not in database")
print("--------------------------------------------")
print("\n".join(files_not_in_db))
print("\n")
if paths_not_found:
print("Invalid paths in database")
print("-------------------------")
for t in paths_not_found:
print(
f"""
Track ID: {t.id}
Path: {t.path}
Title: {t.title}
Artist: {t.artist}
"""
)
if more_files_to_report:
print("There were more paths than listed that were not found")
def update_bitrates(session: Session) -> None:
"""
Update bitrates on all tracks in database
"""
for track in Tracks.get_all(session):
try:
t = get_tags(track.path)
track.bitrate = t.bitrate
except FileNotFoundError:
continue

View File

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

View File

@ -1,190 +0,0 @@
#!/usr/bin/python3
# vim: set expandtab tabstop=4 shiftwidth=4:
# PyQt Functionality Snippet by Apocalyptech
# "Licensed" in the Public Domain under CC0 1.0 Universal (CC0 1.0)
# Public Domain Dedication. Use it however you like!
#
# https://creativecommons.org/publicdomain/zero/1.0/
# https://creativecommons.org/publicdomain/zero/1.0/legalcode
from PyQt6 import QtWidgets, QtCore
# class MyModel(QtGui.QStandardItemModel):
class MyModel(QtCore.QAbstractTableModel):
def __init__(self, parent=None):
super().__init__(parent)
def columnCount(self, parent=None):
return 5
def rowCount(self, parent=None):
return 20
# def headerData(self, column: int, orientation, role: QtCore.Qt.ItemDataRole):
# return (('Regex', 'Category')[column]
# if role == QtCore.Qt.DisplayRole and orientation == QtCore.Qt.Horizontal
# else None)
def headerData(self, column, orientation, role):
if role == QtCore.Qt.ItemDataRole.DisplayRole and orientation == QtCore.Qt.Orientation.Horizontal:
return f"{column=}"
return None
def data(self, index: QtCore.QModelIndex, role: QtCore.Qt.ItemDataRole):
if not index.isValid() or role not in {
QtCore.Qt.ItemDataRole.DisplayRole,
QtCore.Qt.ItemDataRole.EditRole,
}:
return None
# return (self._data[index.row()][index.column()] if index.row() < len(self._data) else
# "edit me" if role == QtCore.Qt.DisplayRole else "")
# def data(self, index, role):
# if not index.isValid() or role not in [QtCore.Qt.DisplayRole,
# QtCore.Qt.EditRole]:
# return None
# return (self._data[index.row()][index.column()] if index.row() < len(self._data) else
# "edit me" if role == QtCore.Qt.DisplayRole else "")
row = index.row()
column = index.column()
return f"Row {row}, Col {column}"
def flags(self, index: QtCore.QModelIndex) -> QtCore.Qt.ItemFlag:
# https://doc.qt.io/qt-5/qt.html#ItemFlag-enum
if not index.isValid():
return QtCore.Qt.ItemFlag.ItemIsEnabled
if index.row() < 20:
return (
QtCore.Qt.ItemFlag.ItemIsEnabled
| QtCore.Qt.ItemFlag.ItemIsEditable
| QtCore.Qt.ItemFlag.ItemIsSelectable
| QtCore.Qt.ItemFlag.ItemIsDragEnabled
)
return QtCore.Qt.ItemFlag.ItemIsEnabled | QtCore.Qt.ItemFlag.ItemIsEditable
# def flags(self, index):
# if not index.isValid():
# return QtCore.Qt.ItemIsDropEnabled
# if index.row() < 5:
# return (
# QtCore.Qt.ItemIsEnabled
# | QtCore.Qt.ItemIsEditable
# | QtCore.Qt.ItemIsSelectable
# | QtCore.Qt.ItemIsDragEnabled
# )
# return QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsEditable
# def supportedDragOptions(self):
# return QtCore.Qt.MoveAction | QtCore.Qt.CopyAction
# def supportedDropActions(self) -> bool:
# return QtCore.Qt.MoveAction | QtCore.Qt.CopyAction
def relocateRow(self, row_source, row_target) -> None:
return
row_a, row_b = max(row_source, row_target), min(row_source, row_target)
self.beginMoveRows(
QtCore.QModelIndex(), row_a, row_a, QtCore.QModelIndex(), row_b
)
self._data.insert(row_target, self._data.pop(row_source))
self.endMoveRows()
def supportedDropActions(self):
return QtCore.Qt.DropAction.MoveAction | QtCore.Qt.DropAction.CopyAction
# def relocateRow(self, src, dst):
# print("relocateRow")
# def dropMimeData(self, data, action, row, col, parent):
# """
# Always move the entire row, and don't allow column "shifting"
# """
# # return super().dropMimeData(data, action, row, 0, parent)
# print("dropMimeData")
# super().dropMimeData(data, action, row, col, parent)
class MyStyle(QtWidgets.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. This may not always work depending on global
style - for instance I think it won't work on OSX.
"""
if element == QtWidgets.QStyle.PrimitiveElement.PE_IndicatorItemViewItemDrop and not option.rect.isNull():
option_new = QtWidgets.QStyleOption(option)
option_new.rect.setLeft(0)
if widget:
option_new.rect.setRight(widget.width())
option = option_new
super().drawPrimitive(element, option, painter, widget)
class MyTableView(QtWidgets.QTableView):
def __init__(self, parent):
super().__init__(parent)
self.verticalHeader().hide()
self.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectionBehavior.SelectRows)
self.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.ExtendedSelection)
self.setDragDropMode(QtWidgets.QAbstractItemView.DragDropMode.InternalMove)
self.setDragDropOverwriteMode(False)
self.setAcceptDrops(True)
# self.horizontalHeader().hide()
# self.horizontalHeader().setSectionResizeMode(QtWidgets.QHeaderView.Stretch)
# self.setShowGrid(False)
# Set our custom style - this draws the drop indicator across the whole row
self.setStyle(MyStyle())
# Set our custom model - this prevents row "shifting"
# self.model = MyModel()
# self.setModel(self.model)
self.setModel(MyModel())
# for (idx, data) in enumerate(['foo', 'bar', 'baz']):
# item_1 = QtGui.QStandardItem('Item {}'.format(idx))
# item_1.setEditable(False)
# item_1.setDropEnabled(False)
# item_2 = QtGui.QStandardItem(data)
# item_2.setEditable(False)
# item_2.setDropEnabled(False)
# self.model.appendRow([item_1, item_2])
def dropEvent(self, event):
if event.source() is not self or (
event.dropAction() != QtCore.Qt.DropAction.MoveAction
and self.dragDropMode() != QtWidgets.QAbstractItemView.InternalMove
):
super().dropEvent(event)
from_rows = list(set([a.row() for a in self.selectedIndexes()]))
to_row = self.indexAt(event.position().toPoint()).row()
if (
0 <= min(from_rows) <= self.model().rowCount()
and 0 <= max(from_rows) <= self.model().rowCount()
and 0 <= to_row <= self.model().rowCount()
):
print(f"move_rows({from_rows=}, {to_row=})")
event.accept()
super().dropEvent(event)
class Testing(QtWidgets.QMainWindow):
def __init__(self):
super().__init__()
view = MyTableView(self)
view.setModel(MyModel())
self.setCentralWidget(view)
self.show()
if __name__ == "__main__":
app = QtWidgets.QApplication([])
test = Testing()
raise SystemExit(app.exec())

View File

@ -1,11 +1,11 @@
#!/usr/bin/python3
import datetime as dt
from datetime import datetime, timedelta
from threading import Timer
from pydub import AudioSegment
from time import sleep
from timeloop import Timeloop # type: ignore
import vlc # type: ignore
from timeloop import Timeloop
import vlc
class RepeatedTimer(object):
@ -49,9 +49,9 @@ def leading_silence(audio_segment, silence_threshold=-50.0, chunk_size=10):
trim_ms = 0 # ms
assert chunk_size > 0 # to avoid infinite loop
while audio_segment[
trim_ms : trim_ms + chunk_size
].dBFS < silence_threshold and trim_ms < len(audio_segment):
while (
audio_segment[trim_ms:trim_ms + chunk_size].dBFS < silence_threshold
and trim_ms < len(audio_segment)):
trim_ms += chunk_size
# if there is no end it should return the length of the segment
@ -73,8 +73,7 @@ def significant_fade(audio_segment, fade_threshold=-20.0, chunk_size=10):
trim_ms = segment_length - chunk_size
while (
audio_segment[trim_ms:trim_ms + chunk_size].dBFS < fade_threshold
and trim_ms > 0
):
and trim_ms > 0):
trim_ms -= chunk_size
# if there is no trailing silence, return lenght of track (it's less
@ -96,8 +95,7 @@ def trailing_silence(audio_segment, silence_threshold=-50.0, chunk_size=10):
trim_ms = segment_length - chunk_size
while (
audio_segment[trim_ms:trim_ms + chunk_size].dBFS < silence_threshold
and trim_ms > 0
):
and trim_ms > 0):
trim_ms -= chunk_size
# if there is no trailing silence, return lenght of track (it's less
@ -126,17 +124,15 @@ def update_progress(player, talk_at, silent_at):
remaining_time = total_time - elapsed_time
talk_time = remaining_time - (total_time - talk_at)
silent_time = remaining_time - (total_time - silent_at)
end_time = (dt.datetime.now() + timedelta(milliseconds=remaining_time)).strftime(
"%H:%M:%S"
)
end_time = (datetime.now() + timedelta(
milliseconds=remaining_time)).strftime("%H:%M:%S")
print(
f"\t{ms_to_mmss(elapsed_time)}/"
f"{ms_to_mmss(total_time)}\t\t"
f"Talk in: {ms_to_mmss(talk_time)} "
f"Silent in: {ms_to_mmss(silent_time)} "
f"Ends at: {end_time} [{ms_to_mmss(remaining_time)}]",
end="\r",
)
f"Ends at: {end_time} [{ms_to_mmss(remaining_time)}]"
, end="\r")
# Print name of current song, print name of next song. Play current when

View File

@ -1,84 +0,0 @@
#!/usr/bin/env python3
from sqlalchemy import create_engine, String, update, bindparam, case
from sqlalchemy.orm import (
DeclarativeBase,
Mapped,
mapped_column,
sessionmaker,
scoped_session,
)
from typing import Generator
from contextlib import contextmanager
db_url = "sqlite:////tmp/rhys.db"
class Base(DeclarativeBase):
pass
class Rhys(Base):
__tablename__ = "rhys"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
ref_number: Mapped[int] = mapped_column()
name: Mapped[str] = mapped_column(String(256), index=True)
def __init__(self, session, ref_number: int, name: str) -> None:
self.ref_number = ref_number
self.name = name
session.add(self)
session.flush()
@contextmanager
def Session() -> Generator[scoped_session, None, None]:
Session = scoped_session(sessionmaker(bind=engine))
yield Session
Session.commit()
Session.close()
engine = create_engine(db_url)
Base.metadata.create_all(engine)
inital_number_of_records = 10
def move_rows(session):
new_row = 6
with Session() as session:
# new_record = Rhys(session, new_row, f"new {new_row=}")
# Move rows
stmt = (
update(Rhys)
.where(Rhys.ref_number > new_row)
# .where(Rhys.id.in_(session.query(Rhys.id).order_by(Rhys.id.desc())))
.values({Rhys.ref_number: Rhys.ref_number + 1})
)
session.execute(stmt)
sqla_map = []
for k, v in zip(range(11), [0, 1, 2, 3, 4, 7, 8, 10, 5, 6, 9]):
sqla_map.append({"oldrow": k, "newrow": v})
# for a, b in sqla_map.items():
# print(f"{a} > {b}")
with Session() as session:
for a in range(inital_number_of_records):
_ = Rhys(session, a, f"record: {a}")
stmt = update(Rhys).values(
ref_number=case(
{item['oldrow']: item['newrow'] for item in sqla_map},
value=Rhys.ref_number
)
)
session.connection().execute(stmt, sqla_map)

View File

@ -1,101 +0,0 @@
#!/usr/bin/env python3
# https://stackoverflow.com/questions/26227885/drag-and-drop-rows-within-qtablewidget
import sys
from PyQt6.QtWidgets import (
QTableWidget,
QAbstractItemView,
QTableWidgetItem,
QWidget,
QHBoxLayout,
QApplication,
)
from PyQt6.QtCore import Qt
class TableWidgetDragRows(QTableWidget):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.setDragEnabled(True)
self.setAcceptDrops(True)
self.viewport().setAcceptDrops(True)
self.setDragDropOverwriteMode(False)
self.setDropIndicatorShown(True)
self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove)
self.DropIndicatorPosition(QAbstractItemView.DropIndicatorPosition.BelowItem)
def dropEvent(self, event):
if event.source() == self:
rows = set([mi.row() for mi in self.selectedIndexes()])
targetRow = self.indexAt(event.position().toPoint()).row()
if self.dropIndicatorPosition() == QAbstractItemView.DropIndicatorPosition.BelowItem:
targetRow += 1
rows.discard(targetRow)
rows = sorted(rows)
if not rows:
return
if targetRow == -1:
targetRow = self.rowCount()
for _ in range(len(rows)):
self.insertRow(targetRow)
rowMapping = dict() # Src row to target row.
for idx, row in enumerate(rows):
if row < targetRow:
rowMapping[row] = targetRow + idx
else:
rowMapping[row + len(rows)] = targetRow + idx
colCount = self.columnCount()
for srcRow, tgtRow in sorted(rowMapping.items()):
for col in range(0, colCount):
self.setItem(tgtRow, col, self.takeItem(srcRow, col))
for row in reversed(sorted(rowMapping.keys())):
self.removeRow(row)
event.accept()
return
class Window(QWidget):
def __init__(self):
super(Window, self).__init__()
layout = QHBoxLayout()
self.setLayout(layout)
self.table_widget = TableWidgetDragRows()
layout.addWidget(self.table_widget)
# setup table widget
self.table_widget.setColumnCount(2)
self.table_widget.setHorizontalHeaderLabels(["Type", "Name"])
items = [
("Red", "Toyota"),
("Blue", "RV"),
("Green", "Beetle"),
("Silver", "Chevy"),
("Black", "BMW"),
]
self.table_widget.setRowCount(len(items))
for i, (color, model) in enumerate(items):
item_flags = Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsDragEnabled
colour_item = QTableWidgetItem(color)
colour_item.setFlags(item_flags)
model_item = QTableWidgetItem(model)
model_item.setFlags(item_flags)
self.table_widget.setItem(i, 0, QTableWidgetItem(color))
self.table_widget.setItem(i, 1, QTableWidgetItem(model))
self.resize(400, 400)
self.show()
if __name__ == "__main__":
app = QApplication(sys.argv)
window = Window()
sys.exit(app.exec())

View File

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

View File

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

View File

@ -1,98 +0,0 @@
#!/usr/bin/env python
# vim: set expandtab tabstop=4 shiftwidth=4:
# PyQt Functionality Snippet by Apocalyptech
# "Licensed" in the Public Domain under CC0 1.0 Universal (CC0 1.0)
# Public Domain Dedication. Use it however you like!
#
# https://creativecommons.org/publicdomain/zero/1.0/
# https://creativecommons.org/publicdomain/zero/1.0/legalcode
import sys
from PyQt5 import QtWidgets, QtGui, QtCore
class MyModel(QtGui.QStandardItemModel):
def dropMimeData(self, data, action, row, col, parent):
"""
Always move the entire row, and don't allow column "shifting"
"""
return super().dropMimeData(data, action, row, 0, parent)
class MyStyle(QtWidgets.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. This may not always work depending on global
style - for instance I think it won't work on OSX.
"""
if element == self.PE_IndicatorItemViewItemDrop and not option.rect.isNull():
option_new = QtWidgets.QStyleOption(option)
option_new.rect.setLeft(0)
if widget:
option_new.rect.setRight(widget.width())
option = option_new
super().drawPrimitive(element, option, painter, widget)
class MyTableView(QtWidgets.QTableView):
def __init__(self, parent):
super().__init__(parent)
self.verticalHeader().hide()
self.horizontalHeader().hide()
self.horizontalHeader().setSectionResizeMode(QtWidgets.QHeaderView.Stretch)
self.setSelectionBehavior(self.SelectRows)
self.setSelectionMode(self.SingleSelection)
self.setShowGrid(False)
self.setDragDropMode(self.InternalMove)
self.setDragDropOverwriteMode(False)
# Set our custom style - this draws the drop indicator across the whole row
self.setStyle(MyStyle())
# Set our custom model - this prevents row "shifting"
self.model = MyModel()
self.setModel(self.model)
for (idx, data) in enumerate(['foo', 'bar', 'baz']):
item_1 = QtGui.QStandardItem('Item {}'.format(idx))
item_1.setEditable(False)
item_1.setDropEnabled(False)
item_2 = QtGui.QStandardItem(data)
item_2.setEditable(False)
item_2.setDropEnabled(False)
self.model.appendRow([item_1, item_2])
class Testing(QtWidgets.QMainWindow):
def __init__(self):
super().__init__()
# Main widget
w = QtWidgets.QWidget()
l = QtWidgets.QVBoxLayout()
w.setLayout(l)
self.setCentralWidget(w)
# spacer
l.addWidget(QtWidgets.QLabel('top'), 1)
# Combo Box
l.addWidget(MyTableView(self))
# spacer
l.addWidget(QtWidgets.QLabel('bottom'), 1)
# A bit of window housekeeping
self.resize(400, 400)
self.setWindowTitle('Testing')
self.show()
if __name__ == '__main__':
app = QtWidgets.QApplication([])
test = Testing()
sys.exit(app.exec_())

View File

@ -1,3 +1,5 @@
# tl = Timeloop()
#
#

BIN
archive/todo/.DS_Store vendored

Binary file not shown.

View File

@ -1 +0,0 @@
[[false, "My first todo"], [true, "My second todo"], [true, "Another todo"], [false, "as"]]

View File

@ -1,71 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>MainWindow</class>
<widget class="QMainWindow" name="MainWindow">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>275</width>
<height>314</height>
</rect>
</property>
<property name="windowTitle">
<string>Todo</string>
</property>
<widget class="QWidget" name="centralwidget">
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QListView" name="todoView">
<property name="selectionMode">
<enum>QAbstractItemView::SingleSelection</enum>
</property>
</widget>
</item>
<item>
<widget class="QWidget" name="widget" native="true">
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<widget class="QPushButton" name="deleteButton">
<property name="text">
<string>Delete</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="completeButton">
<property name="text">
<string>Complete</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QLineEdit" name="todoEdit"/>
</item>
<item>
<widget class="QPushButton" name="addButton">
<property name="text">
<string>Add Todo</string>
</property>
</widget>
</item>
</layout>
</widget>
<widget class="QMenuBar" name="menubar">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>275</width>
<height>22</height>
</rect>
</property>
</widget>
<widget class="QStatusBar" name="statusbar"/>
</widget>
<resources/>
<connections/>
</ui>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 634 B

View File

@ -1,104 +0,0 @@
import sys
import datetime
import json
from PyQt5 import QtCore, QtGui, QtWidgets, uic
from PyQt5.QtCore import Qt
qt_creator_file = "mainwindow.ui"
Ui_MainWindow, QtBaseClass = uic.loadUiType(qt_creator_file)
tick = QtGui.QImage("tick.png")
class TodoModel(QtCore.QAbstractListModel):
def __init__(self, *args, todos=None, **kwargs):
super(TodoModel, self).__init__(*args, **kwargs)
self.todos = todos or []
def data(self, index, role):
if role == Qt.DisplayRole:
_, text = self.todos[index.row()]
return text
if role == Qt.DecorationRole:
status, _ = self.todos[index.row()]
if status:
return tick
def rowCount(self, index):
return len(self.todos)
def flags(self, index):
print(datetime.datetime.now().time().strftime("%H:%M:%S"))
return super().flags(index)
class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow):
def __init__(self):
QtWidgets.QMainWindow.__init__(self)
Ui_MainWindow.__init__(self)
self.setupUi(self)
self.model = TodoModel()
self.load()
self.todoView.setModel(self.model)
self.addButton.pressed.connect(self.add)
self.deleteButton.pressed.connect(self.delete)
self.completeButton.pressed.connect(self.complete)
def add(self):
"""
Add an item to our todo list, getting the text from the QLineEdit .todoEdit
and then clearing it.
"""
text = self.todoEdit.text()
if text: # Don't add empty strings.
# Access the list via the model.
self.model.todos.append((False, text))
# Trigger refresh.
self.model.layoutChanged.emit()
# Empty the input
self.todoEdit.setText("")
self.save()
def delete(self):
indexes = self.todoView.selectedIndexes()
if indexes:
# Indexes is a list of a single item in single-select mode.
index = indexes[0]
# Remove the item and refresh.
del self.model.todos[index.row()]
self.model.layoutChanged.emit()
# Clear the selection (as it is no longer valid).
self.todoView.clearSelection()
self.save()
def complete(self):
indexes = self.todoView.selectedIndexes()
if indexes:
index = indexes[0]
row = index.row()
status, text = self.model.todos[row]
self.model.todos[row] = (True, text)
# .dataChanged takes top-left and bottom right, which are equal
# for a single selection.
self.model.dataChanged.emit(index, index)
# Clear the selection (as it is no longer valid).
self.todoView.clearSelection()
self.save()
def load(self):
try:
with open("data.db", "r") as f:
self.model.todos = json.load(f)
except Exception:
pass
def save(self):
with open("data.db", "w") as f:
data = json.dump(self.model.todos, f)
app = QtWidgets.QApplication(sys.argv)
window = MainWindow()
window.show()
app.exec_()

View File

@ -1,22 +0,0 @@
diff --git a/app/musicmuster.py b/app/musicmuster.py
index d90644b..b53f3a8 100755
--- a/app/musicmuster.py
+++ b/app/musicmuster.py
@@ -1803,6 +1803,17 @@ class Window(QMainWindow, Ui_MainWindow):
else:
self.hdrNextTrack.setText("")
+ def wheelEvent(self, event):
+ """
+ Detect wheel events
+ """
+
+ delta = event.angleDelta().y() / 120
+ if delta > 0:
+ print("musicmuster.py Scroll Up")
+ else:
+ print("musicmuster.py Scroll Down")
+
class CartDialog(QDialog):
"""Edit cart details"""

80
audacity_control.py Executable file
View File

@ -0,0 +1,80 @@
#!/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
def quick_test():
"""Example list of commands."""
do_command('Help: Command=Help')
do_command('Help: Command="GetInfo"')
# do_command('SetPreference: Name=GUI/Theme Value=classic Reload=1')
do_command('Import2: Filename=/home/kae/git/musicmuster/archive/boot.flac')

View File

@ -1,20 +0,0 @@
# Minimal makefile for Sphinx documentation
#
# You can set these variables from the command line, and also
# from the environment for the first two.
SPHINXOPTS ?=
SPHINXBUILD ?= sphinx-build
SOURCEDIR = source
BUILDDIR = build
# Put it first so that "make" without argument is like "make help".
help:
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
.PHONY: help Makefile
# Catch-all target: route all unknown targets to Sphinx using the new
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
%: Makefile
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,4 +0,0 @@
# Sphinx build info version 1
# This file hashes the configuration used when building these files. When it is not found, a full rebuild will be done.
config: dcac9a6ec03f5a33392ad4b5bcf42637
tags: 645f666f9bcd5a90fca523b33c5a78b7

View File

@ -1,2 +0,0 @@
Development
===========

View File

@ -1,25 +0,0 @@
.. MusicMuster documentation master file, created by
sphinx-quickstart on Sun Jul 2 17:58:44 2023.
Welcome to MusicMuster's documentation!
=======================================
**MusicMuster** is a music player targeted at the production of live
internet radio shows.
Contents
--------
.. toctree::
introduction
installation
tutorial
reference
development
Indices and tables
==================
* :ref:`genindex`
* :ref:`modindex`
* :ref:`search`

Some files were not shown because too many files have changed in this diff Show More