Compare commits

..

77 Commits

Author SHA1 Message Date
Keith Edmunds
fe4b1f8b5e Crude track import 2022-04-05 22:12:05 +01:00
Keith Edmunds
1abee60827 Correct prod pw/allow adding multiple db tracks 2022-04-05 21:23:30 +01:00
Keith Edmunds
6ca37bc45a Protect music player during fade 2022-04-05 21:23:30 +01:00
Keith Edmunds
558a283e73 Detect music playing better 2022-04-05 21:03:28 +01:00
Keith Edmunds
fe660524a0 All tests passing 2022-04-05 17:00:29 +01:00
Keith Edmunds
805053b795 Improve performance selecting multiple tracks 2022-04-04 21:30:49 +01:00
Keith Edmunds
c5f33c437f Fix moving tracks between playlists 2022-04-04 21:30:31 +01:00
Keith Edmunds
0a3700e208 Correct production database credentials 2022-04-04 21:28:54 +01:00
Keith Edmunds
976eb91e30 Fix move selected tracks 2022-03-20 22:40:38 +00:00
Keith Edmunds
ebfdf98612 Polish typing, explicit returns to terminate context managers 2022-03-20 18:56:59 +00:00
Keith Edmunds
0fb1536055 Add section timing 2022-03-20 11:42:05 +00:00
Keith Edmunds
ca385dcf54 Remove test function 2022-03-19 23:24:42 +00:00
Keith Edmunds
0d865f05ac Clean up dbconfig session handling 2022-03-19 23:24:18 +00:00
Keith Edmunds
75b814e26c Session acquisitiong logging 2022-03-19 20:30:14 +00:00
Keith Edmunds
47f53428f6 Session fixes, MSS colour 2022-03-19 20:20:22 +00:00
Keith Edmunds
e2c5bba4ae Minor tweak to tests 2022-03-15 21:29:36 +00:00
Keith Edmunds
7f046ae86b test_playlists complete and working 2022-03-14 21:40:15 +00:00
Keith Edmunds
a27dd7189a Fix tests 2022-03-14 20:13:14 +00:00
Keith Edmunds
34fa0c92b2 Update packages 2022-03-14 20:11:11 +00:00
Keith Edmunds
87f9e1e81b Merge 2022-03-14 20:10:59 +00:00
Keith Edmunds
a31718d2b9 Separate db config, testing session for pytest 2022-03-14 20:10:07 +00:00
Keith Edmunds
cf4e42358e First moves to separate db config 2022-03-14 20:09:38 +00:00
Keith Edmunds
4ce6c2e9b9 Add pytest-qt back in 2022-03-12 08:28:22 +00:00
Keith Edmunds
fbca77debe Package management so tests start passing 2022-03-12 08:21:00 +00:00
Keith Edmunds
3ebb3e1acf Enhance tests 2022-03-11 23:04:09 +00:00
Keith Edmunds
f0b9ab4256 Fix up remove track from playlist 2022-03-09 21:40:47 +00:00
Keith Edmunds
2b02c1b5b4 profiling 2022-03-06 12:05:12 +00:00
Keith Edmunds
a882d409cb Session sanity 2022-03-04 22:59:01 +00:00
Keith Edmunds
2186b3eb09 Record playlist opening and closing
Also fixes #95
2022-03-04 18:55:02 +00:00
Keith Edmunds
06efaf2ba2 Fix tests 2022-03-04 18:34:31 +00:00
Keith Edmunds
9c0371d41c Scroll current row to top; improve session handling 2022-03-04 18:17:57 +00:00
Keith Edmunds
e7004688d0 Add configurable web zoom factor 2022-03-04 16:53:10 +00:00
Keith Edmunds
8c69f108cb Change LH clock box
Fixes #102, #99
2022-03-04 16:01:20 +00:00
Keith Edmunds
f22f209bee Fix some type hints 2022-03-03 18:30:13 +00:00
Keith Edmunds
1c56505ab0 Fix playlist export 2022-03-03 18:30:00 +00:00
Keith Edmunds
ca1b11b545 Fix select all (un)played tracks 2022-03-03 17:30:37 +00:00
Keith Edmunds
9397adee03 Don't allow active tab to be closed 2022-03-02 22:04:53 +00:00
Keith Edmunds
4a83e9af86 Revamp menus 2022-03-02 21:13:41 +00:00
Keith Edmunds
f22f2780a3 Fix move tracks 2022-03-02 20:37:27 +00:00
Keith Edmunds
f1aba41921 Update all packages 2022-03-02 09:34:52 +00:00
Keith Edmunds
a2fb6baba8 Rebase dev onto v2_id 2022-03-02 09:30:26 +00:00
Keith Edmunds
08eea631d6 Rebase dev onto v2_id 2022-03-02 09:27:37 +00:00
Keith Edmunds
d62a044522 Fix typo 2022-03-02 09:27:12 +00:00
Keith Edmunds
e8211414f9 V2 using ids rather than objects. Looking good. 2022-03-02 09:27:12 +00:00
Keith Edmunds
26edd5a2d0 more session stuff 2022-03-02 09:27:12 +00:00
Keith Edmunds
bc6a4c11cf Rebase dev onto v2_id 2022-03-02 09:27:10 +00:00
Keith Edmunds
a91309477b Rebase dev onto v2_id 2022-03-02 09:25:59 +00:00
Keith Edmunds
3a7b09f025 Code cleanups 2022-03-02 09:24:40 +00:00
Keith Edmunds
7f2dd68bce Rebase dev onto v2_id 2022-03-02 09:24:35 +00:00
Keith Edmunds
281a1d40bf Rebase dev onto v2_id 2022-03-02 09:23:56 +00:00
Keith Edmunds
cf58932fca Rebase dev onto v2_id 2022-03-02 09:16:07 +00:00
Keith Edmunds
b92a0927f8 Get row and track from playlist.tracks with tests 2022-03-02 09:14:52 +00:00
Keith Edmunds
ab9955b88a v2 tidy/refactor 2022-03-02 09:14:52 +00:00
Keith Edmunds
b00f70ff4b v2 tidy/refactor 2022-03-02 09:14:52 +00:00
Keith Edmunds
9fb05079dc All helper tests pass 2022-03-02 09:14:52 +00:00
Keith Edmunds
1c86728170 Added .rescan to Tracks
Also added tests for rescan function
2022-03-02 09:14:52 +00:00
Keith Edmunds
557b89ba09 Refactoring and tests for models complete (for now) 2022-03-02 09:14:52 +00:00
Keith Edmunds
7cd2d610b1 playlist.tracks now association object plus refactoring 2022-03-02 09:14:52 +00:00
Keith Edmunds
ac27486317 Rebase dev onto v2_id 2022-03-02 09:14:44 +00:00
Keith Edmunds
907861ea48 Rebase dev onto v2_id 2022-03-02 09:13:43 +00:00
Keith Edmunds
04c3c2efbc Refactoring 2022-03-02 09:13:11 +00:00
Keith Edmunds
fa2e1234e9 Remove redundant functions and tests 2022-03-02 09:13:11 +00:00
Keith Edmunds
fec45925c6 Remove redundant functions and tests 2022-03-02 09:13:11 +00:00
Keith Edmunds
fcebe2f220 Rebase dev onto v2_id 2022-03-02 09:12:55 +00:00
Keith Edmunds
00f85a9a96 Add PyCharm config files to git 2022-03-02 09:12:00 +00:00
Keith Edmunds
f3bf829ef3 Rebase dev onto v2_id 2022-03-02 09:11:52 +00:00
Keith Edmunds
1a0cac22f6 Added more tests in test_models 2022-03-02 09:11:10 +00:00
Keith Edmunds
9aa6941fca Added first few tests in test_models 2022-03-02 09:11:10 +00:00
Keith Edmunds
a164f4c962 Rebase dev onto v2_id branch 2022-03-02 09:10:46 +00:00
Keith Edmunds
db86d04b9a Make alembic.ini safe
All database URLs are commented out. The appropriate one should be
uncommented when needed.
2022-03-02 09:08:27 +00:00
Keith Edmunds
3cab7a8376 Auto-create MySQL connect string in env vars 2022-03-02 09:08:27 +00:00
Keith Edmunds
2015dcce1f Use colour rather than hexcolour in notecolours table 2022-03-02 09:08:27 +00:00
Keith Edmunds
03735c2456 Add ipdb to dev tools 2022-02-26 09:26:44 +00:00
Keith Edmunds
b283a3db07 Warn if colon in track path 2022-02-26 09:26:13 +00:00
Keith Edmunds
cb50fc253b Make current track in playlist lighter green 2022-02-20 11:10:23 +00:00
Keith Edmunds
9d78e5fe57 Update packages 2022-02-12 10:10:27 +00:00
Keith Edmunds
bb648488d6 Add order to colours table 2022-02-05 23:46:43 +00:00
36 changed files with 8355 additions and 2462 deletions

10
.envrc
View File

@ -1 +1,11 @@
layout poetry layout poetry
MYSQL_USER="musicmuster"
MYSQL_PASSWORD="musicmuster"
branch=$(git branch --show-current)
if on_git_branch master; then
MYSQL_DATABASE="musicmuster_prod"
elif on_git_branch v2; then
MYSQL_DATABASE="musicmuster_v2"
else MYSQL_DATABASE="musicmuster_dev"
fi
export MYSQL_CONNECT="mysql+mysqldb://${MYSQL_USER}:${MYSQL_PASSWORD}@localhost/${MYSQL_DATABASE}"

View File

@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" />
</state>
</component>

View File

@ -0,0 +1,3 @@
<component name="ProjectDictionaryState">
<dictionary name="kae" />
</component>

View File

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

View File

@ -1,8 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4"> <module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager"> <component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" /> <content url="file://$MODULE_DIR$">
<orderEntry type="inheritedJdk" /> <sourceFolder url="file://$MODULE_DIR$/app" isTestSource="false" />
</content>
<orderEntry type="jdk" jdkName="Poetry (musicmuster)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" /> <orderEntry type="sourceFolder" forTests="false" />
</component> </component>
<component name="PyDocumentationSettings"> <component name="PyDocumentationSettings">

View File

@ -39,7 +39,10 @@ prepend_sys_path = .
# are written from script.py.mako # are written from script.py.mako
# output_encoding = utf-8 # output_encoding = utf-8
sqlalchemy.url = mysql+mysqldb://musicmuster:musicmuster@localhost/musicmuster_dev sqlalchemy.url = SET
# sqlalchemy.url = mysql+mysqldb://musicmuster:musicmuster@localhost/musicmuster_prod
# sqlalchemy.url = mysql+mysqldb://musicmuster:musicmuster@localhost/musicmuster_dev
# sqlalchemy.url = mysql+mysqldb://musicmuster:musicmuster@localhost/musicmuster_v2
[post_write_hooks] [post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run # post_write_hooks defines scripts or Python functions that are run

0
app/__init__.py Normal file
View File

View File

@ -3,29 +3,43 @@ import os
class Config(object): class Config(object):
AUDACITY_COMMAND = "/usr/bin/audacity"
AUDIO_SEGMENT_CHUNK_SIZE = 10 AUDIO_SEGMENT_CHUNK_SIZE = 10
CHECK_AUDACITY_AT_STARTUP = True
COLOUR_CURRENT_HEADER = "#d4edda" COLOUR_CURRENT_HEADER = "#d4edda"
COLOUR_CURRENT_PLAYLIST = "#28a745" COLOUR_CURRENT_PLAYLIST = "#7eca8f"
COLOUR_CURRENT_TAB = "#248f24" COLOUR_CURRENT_TAB = "#248f24"
COLOUR_ODD_PLAYLIST = "#f2f2f2"
COLOUR_ENDING_TIMER = "#dc3545" COLOUR_ENDING_TIMER = "#dc3545"
COLOUR_EVEN_PLAYLIST = "#d9d9d9" COLOUR_EVEN_PLAYLIST = "#d9d9d9"
COLOUR_LONG_START = "#dc3545" COLOUR_LONG_START = "#dc3545"
COLOUR_NORMAL_TAB = "#000000"
COLOUR_NEXT_HEADER = "#fff3cd" COLOUR_NEXT_HEADER = "#fff3cd"
COLOUR_NEXT_PLAYLIST = "#ffc107" COLOUR_NEXT_PLAYLIST = "#ffc107"
COLOUR_NEXT_TAB = "#b38600" COLOUR_NEXT_TAB = "#b38600"
COLOUR_NORMAL_TAB = "#000000"
COLOUR_NOTES_PLAYLIST = "#b8daff" COLOUR_NOTES_PLAYLIST = "#b8daff"
COLOUR_ODD_PLAYLIST = "#f2f2f2"
COLOUR_PREVIOUS_HEADER = "#f8d7da" COLOUR_PREVIOUS_HEADER = "#f8d7da"
COLOUR_UNREADABLE = "#dc3545" COLOUR_UNREADABLE = "#dc3545"
COLOUR_WARNING_TIMER = "#ffc107" COLOUR_WARNING_TIMER = "#ffc107"
COLUMN_NAME_ARTIST = "Artist"
COLUMN_NAME_AUTOPLAY = "A"
COLUMN_NAME_END_TIME = "End"
COLUMN_NAME_LAST_PLAYED = "Last played"
COLUMN_NAME_LEADING_SILENCE = "Gap"
COLUMN_NAME_LENGTH = "Length"
COLUMN_NAME_START_TIME = "Start"
COLUMN_NAME_TITLE = "Title"
DBFS_FADE = -12 DBFS_FADE = -12
DBFS_SILENCE = -50 DBFS_SILENCE = -50
DEFAULT_COLUMN_WIDTH = 200
DEFAULT_IMPORT_DIRECTORY = "/home/kae/Nextcloud/tmp"
DEFAULT_OUTPUT_DIRECTORY = "/home/kae/music/Singles"
DISPLAY_SQL = False DISPLAY_SQL = False
ERRORS_TO = ['kae@midnighthax.com'] ERRORS_TO = ['kae@midnighthax.com']
FADE_STEPS = 20 FADE_STEPS = 20
FADE_TIME = 3000 FADE_TIME = 3000
INFO_TAB_TITLE_LENGTH = 15 INFO_TAB_TITLE_LENGTH = 15
INFO_TAB_URL = "https://www.wikipedia.org/w/index.php?search=%s"
LOG_LEVEL_STDERR = logging.INFO LOG_LEVEL_STDERR = logging.INFO
LOG_LEVEL_SYSLOG = logging.DEBUG LOG_LEVEL_SYSLOG = logging.DEBUG
LOG_NAME = "musicmuster" LOG_NAME = "musicmuster"
@ -36,19 +50,17 @@ class Config(object):
MAIL_USE_TLS = os.environ.get('MAIL_USE_TLS') is not None MAIL_USE_TLS = os.environ.get('MAIL_USE_TLS') is not None
MAX_INFO_TABS = 3 MAX_INFO_TABS = 3
MILLISECOND_SIGFIGS = 0 MILLISECOND_SIGFIGS = 0
MYSQL_CONNECT = os.environ.get('MYSQL_CONNECT') or "mysql+mysqldb://musicmuster:musicmuster@localhost/musicmuster_dev" # noqa E501 MYSQL_CONNECT = os.environ.get('MYSQL_CONNECT') or "mysql+mysqldb://musicmuster:musicmuster@localhost/musicmuster_v2" # noqa E501
NORMALISE_ON_IMPORT = True NORMALISE_ON_IMPORT = True
NOTE_COLOURS = { NOTE_TIME_FORMAT = "%H:%M:%S"
'track': "#ffff00",
'request': "#7cf000",
'wrap': "#fffacd",
'this month then': "#c256c2",
'story': "#dda0dd",
}
ROOT = os.environ.get('ROOT') or "/home/kae/music" ROOT = os.environ.get('ROOT') or "/home/kae/music"
SCROLL_TOP_MARGIN = 3
TESTMODE = True TESTMODE = True
TOD_TIME_FORMAT = "%H:%M:%S"
TIMER_MS = 500 TIMER_MS = 500
TRACK_TIME_FORMAT = "%H:%M:%S"
VOLUME_VLC_DEFAULT = 75 VOLUME_VLC_DEFAULT = 75
WEB_ZOOM_FACTOR = 1.4
config = Config config = Config

72
app/dbconfig.py Normal file
View File

@ -0,0 +1,72 @@
import inspect
import os
import sqlalchemy
from config import Config
from contextlib import contextmanager
from log import DEBUG
from sqlalchemy.orm import (sessionmaker, scoped_session)
class Counter:
def __init__(self):
self.count = 0
def __repr__(self):
return(f"<Counter({self.count=})>")
def inc(self):
self.count += 1
return self.count
def dec(self):
self.count -= 1
return self.count
MM_ENV = os.environ.get('MM_ENV', 'PRODUCTION')
testing = False
if MM_ENV == 'PRODUCTION':
dbname = os.environ.get('MM_PRODUCTION_DBNAME', 'musicmuster_prod')
dbuser = os.environ.get('MM_PRODUCTION_DBUSER', 'musicmuster')
dbpw = os.environ.get('MM_PRODUCTION_DBPW', 'musicmuster')
dbhost = os.environ.get('MM_PRODUCTION_DBHOST', 'localhost')
elif MM_ENV == 'TESTING':
dbname = os.environ.get('MM_TESTING_DBNAME', 'musicmuster_testing')
dbuser = os.environ.get('MM_TESTING_DBUSER', 'musicmuster_testing')
dbpw = os.environ.get('MM_TESTING_DBPW', 'musicmuster_testing')
dbhost = os.environ.get('MM_TESTING_DBHOST', 'localhost')
testing = True
elif MM_ENV == 'DEVELOPMENT':
dbname = os.environ.get('MM_DEVELOPMENT_DBNAME', 'musicmuster_dev')
dbuser = os.environ.get('MM_DEVELOPMENT_DBUSER', 'musicmuster')
dbpw = os.environ.get('MM_DEVELOPMENT_DBPW', 'musicmuster')
dbhost = os.environ.get('MM_DEVELOPMENT_DBHOST', 'localhost')
else:
raise ValueError(f"Unknown MusicMuster environment: {MM_ENV=}")
MYSQL_CONNECT = f"mysql+mysqldb://{dbuser}:{dbpw}@{dbhost}/{dbname}"
engine = sqlalchemy.create_engine(
MYSQL_CONNECT,
encoding='utf-8',
echo=Config.DISPLAY_SQL,
pool_pre_ping=True
)
# Session = scoped_session(sessionmaker(bind=engine))
@contextmanager
def Session():
frame = inspect.stack()[2]
file = frame.filename
function = frame.function
lineno = frame.lineno
Session = scoped_session(sessionmaker(bind=engine))
DEBUG(f"Session acquired, {file=}, {function=}, {lineno=}, {Session=}",
True)
yield Session
DEBUG(" Session released", True)
Session.commit()
Session.close()

View File

@ -1,11 +1,78 @@
import os import os
import psutil import psutil
from config import Config
from datetime import datetime from datetime import datetime
from pydub import AudioSegment
from PyQt5.QtWidgets import QMessageBox from PyQt5.QtWidgets import QMessageBox
from tinytag import TinyTag
from typing import Dict, Optional, Union
def get_relative_date(past_date, reference_date=None): def ask_yes_no(title: str, question: str) -> bool:
"""Ask question; return True for yes, False for no"""
button_reply: bool = QMessageBox.question(None, title, question)
return button_reply == QMessageBox.Yes
def fade_point(
audio_segment: AudioSegment, fade_threshold: int = 0,
chunk_size: int = 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: int = audio_segment.duration_seconds * 1000 # ms
trim_ms: int = segment_length - chunk_size
max_vol: int = 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 get_audio_segment(path: str) -> Optional[AudioSegment]:
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_tags(path: str) -> Dict[str, Union[str, int]]:
"""
Return a dictionary of title, artist, duration-in-milliseconds and path.
"""
tag: TinyTag = TinyTag.get(path)
return dict(
title=tag.title,
artist=tag.artist,
duration=int(round(tag.duration, Config.MILLISECOND_SIGFIGS) * 1000),
path=path
)
def get_relative_date(past_date: datetime, reference_date: datetime = None) \
-> str:
""" """
Return how long before reference_date past_date is as string. Return how long before reference_date past_date is as string.
@ -25,6 +92,11 @@ def get_relative_date(past_date, reference_date=None):
if past_date > reference_date: if past_date > reference_date:
return "get_relative_date() past_date is after relative_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) weeks, days = divmod((reference_date.date() - past_date.date()).days, 7)
if weeks == days == 0: if weeks == days == 0:
# Played today, so return time instead # Played today, so return time instead
@ -40,55 +112,37 @@ def get_relative_date(past_date, reference_date=None):
return f"{weeks} {weeks_str}, {days} {days_str} ago" return f"{weeks} {weeks_str}, {days} {days_str} ago"
def open_in_audacity(path): def leading_silence(
audio_segment: AudioSegment,
silence_threshold: int = Config.DBFS_SILENCE,
chunk_size: int = Config.AUDIO_SEGMENT_CHUNK_SIZE):
""" """
Open passed file in Audacity 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
Return True if apparently opened successfully, else False https://github.com/jiaaro/pydub/blob/master/pydub/silence.py
""" """
# Return if audacity not running trim_ms: int = 0 # ms
if "audacity" not in [i.name() for i in psutil.process_iter()]: assert chunk_size > 0 # to avoid infinite loop
return False while (
audio_segment[trim_ms:trim_ms + chunk_size].dBFS < # noqa W504
silence_threshold and trim_ms < len(audio_segment)):
trim_ms += chunk_size
to_pipe = '/tmp/audacity_script_pipe.to.' + str(os.getuid()) # if there is no end it should return the length of the segment
from_pipe = '/tmp/audacity_script_pipe.from.' + str(os.getuid()) return min(trim_ms, len(audio_segment))
EOL = '\n'
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 show_warning(title, msg): def ms_to_mmss(ms: int, decimals: int = 0, negative: bool = False) -> str:
"Display a warning to user" """Convert milliseconds to mm:ss"""
QMessageBox.warning(None, title, msg, buttons=QMessageBox.Cancel) minutes: int
remainder: int
seconds: float
def ms_to_mmss(ms, decimals=0, negative=False):
if not ms: if not ms:
return "-" return "-"
sign = "" sign = ""
@ -107,3 +161,66 @@ def ms_to_mmss(ms, decimals=0, negative=False):
seconds = 59.0 seconds = 59.0
return f"{sign}{minutes:.0f}:{seconds:02.{decimals}f}" return f"{sign}{minutes:.0f}:{seconds:02.{decimals}f}"
def open_in_audacity(path: str) -> Optional[bool]:
"""
Open passed file in Audacity
Return True if apparently opened successfully, else False
"""
# Return if audacity not running
if "audacity" not in [i.name() for i in psutil.process_iter()]:
return False
# Return if path not given
if not path:
return False
to_pipe: str = '/tmp/audacity_script_pipe.to.' + str(os.getuid())
from_pipe: str = '/tmp/audacity_script_pipe.from.' + str(os.getuid())
eol: str = '\n'
def send_command(command: str) -> None:
"""Send a single command."""
to_audacity.write(command + eol)
to_audacity.flush()
def get_response() -> str:
"""Return the command response."""
result: str = ''
line: str = ''
while True:
result += line
line = from_audacity.readline()
if line == '\n' and len(result) > 0:
break
return result
def do_command(command: str) -> str:
"""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 show_warning(title: str, msg: str) -> None:
"""Display a warning to user"""
QMessageBox.warning(None, title, msg, buttons=QMessageBox.Cancel)
def trailing_silence(
audio_segment: AudioSegment, silence_threshold: int = -50,
chunk_size: int = Config.AUDIO_SEGMENT_CHUNK_SIZE):
"""Return fade point from start in milliseconds"""
return fade_point(audio_segment, silence_threshold, chunk_size)

View File

@ -9,7 +9,7 @@ from config import Config
class LevelTagFilter(logging.Filter): class LevelTagFilter(logging.Filter):
"Add leveltag" """Add leveltag"""
def filter(self, record): def filter(self, record):
# Extract the first character of the level name # Extract the first character of the level name
@ -32,9 +32,9 @@ syslog = logging.handlers.SysLogHandler(address='/dev/log')
syslog.setLevel(Config.LOG_LEVEL_SYSLOG) syslog.setLevel(Config.LOG_LEVEL_SYSLOG)
# Filter # Filter
filter = LevelTagFilter() local_filter = LevelTagFilter()
syslog.addFilter(filter) syslog.addFilter(local_filter)
stderr.addFilter(filter) stderr.addFilter(local_filter)
# create formatter and add it to the handlers # create formatter and add it to the handlers
stderr_fmt = logging.Formatter('[%(asctime)s] %(leveltag)s: %(message)s', stderr_fmt = logging.Formatter('[%(asctime)s] %(leveltag)s: %(message)s',
@ -103,6 +103,6 @@ if __name__ == "__main__":
return i() return i()
def i(): def i():
1 / 0 return 1 / 0
f() f()

View File

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

648
app/models.py Normal file
View File

@ -0,0 +1,648 @@
#!/usr/bin/python3
import os.path
import re
from dbconfig import Session
from datetime import datetime
from typing import List, Optional
from pydub import AudioSegment
from sqlalchemy.ext.associationproxy import association_proxy
from sqlalchemy.ext.declarative import declarative_base, DeclarativeMeta
from sqlalchemy import (
Boolean,
Column,
DateTime,
Float,
ForeignKey,
func,
Integer,
String,
UniqueConstraint,
)
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import (
backref,
relationship,
RelationshipProperty
)
from sqlalchemy.orm.collections import attribute_mapped_collection
from sqlalchemy.orm.exc import MultipleResultsFound, NoResultFound
from config import Config
from helpers import (
fade_point,
get_audio_segment,
leading_silence,
trailing_silence,
)
from log import DEBUG, ERROR
Base: DeclarativeMeta = declarative_base()
# Database classes
class NoteColours(Base):
__tablename__ = 'notecolours'
id: int = Column(Integer, primary_key=True, autoincrement=True)
substring: str = Column(String(256), index=False)
colour: str = Column(String(21), index=False)
enabled: bool = Column(Boolean, default=True, index=True)
is_regex: bool = Column(Boolean, default=False, index=False)
is_casesensitive: bool = Column(Boolean, default=False, index=False)
order: int = Column(Integer, index=True)
def __init__(
self, session: Session, substring: str, colour: str,
enabled: bool = True, is_regex: bool = False,
is_casesensitive: bool = False, order: 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.flush()
def __repr__(self) -> str:
return (
f"<NoteColour(id={self.id}, substring={self.substring}, "
f"colour={self.colour}>"
)
@classmethod
def get_all(cls, session: Session) -> Optional[List["NoteColours"]]:
"""Return all records"""
return session.query(cls).all()
@classmethod
def get_by_id(cls, session: Session, note_id: int) -> \
Optional["NoteColours"]:
"""Return record identified by id, or None if not found"""
return session.query(NoteColours).filter(
NoteColours.id == note_id).first()
@staticmethod
def get_colour(session: Session, text: str) -> Optional[str]:
"""
Parse text and return colour string if matched, else None
"""
for rec in (
session.query(NoteColours)
.filter(NoteColours.enabled.is_(True))
.order_by(NoteColours.order)
.all()
):
if rec.is_regex:
flags = re.UNICODE
if not rec.is_casesensitive:
flags |= re.IGNORECASE
p = re.compile(rec.substring, flags)
if p.match(text):
return rec.colour
else:
if rec.is_casesensitive:
if rec.substring in text:
return rec.colour
else:
if rec.substring.lower() in text.lower():
return rec.colour
return None
class Notes(Base):
__tablename__ = 'notes'
id: int = Column(Integer, primary_key=True, autoincrement=True)
playlist_id: int = Column(Integer, ForeignKey('playlists.id'))
playlist: RelationshipProperty = relationship(
"Playlists", back_populates="notes", lazy="joined")
row: int = Column(Integer, nullable=False)
note: str = Column(String(256), index=False)
def __init__(
self, session: Session, playlist_id: int, row: int, text: str) -> None:
"""Create note"""
DEBUG(f"Notes.__init__({playlist_id=}, {row=}, {text=})")
self.playlist_id = playlist_id
self.row = row
self.note = text
session.add(self)
session.flush()
def __repr__(self) -> str:
return (
f"<Note(id={self.id}, row={self.row}, note={self.note}>"
)
def delete_note(self, session: Session) -> None:
"""Delete note"""
DEBUG(f"delete_note({self.id=}")
session.query(Notes).filter_by(id=self.id).delete()
session.flush()
@classmethod
def get_by_id(cls, session: Session, note_id: int) -> Optional["Notes"]:
"""Return note or None"""
try:
DEBUG(f"Notes.get_track(track_id={note_id})")
note = session.query(cls).filter(cls.id == note_id).one()
return note
except NoResultFound:
ERROR(f"get_track({note_id}): not found")
return None
def update_note(
self, session: Session, row: int,
text: Optional[str] = None) -> None:
"""
Update note details. If text=None, don't change text.
"""
DEBUG(f"Notes.update_note({self.id=}, {row=}, {text=})")
self.row = row
if text:
self.note = text
session.flush()
class Playdates(Base):
__tablename__ = 'playdates'
id: int = Column(Integer, primary_key=True, autoincrement=True)
lastplayed: datetime = Column(DateTime, index=True, default=None)
track_id: int = Column(Integer, ForeignKey('tracks.id'))
tracks: RelationshipProperty = relationship(
"Tracks", back_populates="playdates", lazy="joined")
def __init__(self, session: Session, track_id: int) -> None:
"""Record that track was played"""
DEBUG(f"add_playdate({track_id=})")
self.lastplayed = datetime.now()
self.track_id = track_id
session.add(self)
session.flush()
@staticmethod
def last_played(session: Session, track_id: int) -> Optional[datetime]:
"""Return datetime track last played or None"""
last_played: Optional[Playdates] = 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: Session, track_id: int) -> None:
"""
Remove all records of track_id
"""
session.query(Playdates).filter(
Playdates.track_id == track_id).delete()
session.flush()
class Playlists(Base):
"""
Manage playlists
"""
__tablename__ = "playlists"
id: int = Column(Integer, primary_key=True, autoincrement=True)
name: str = Column(String(32), nullable=False, unique=True)
last_used: datetime = Column(DateTime, default=None, nullable=True)
loaded: bool = Column(Boolean, default=True, nullable=False)
notes = relationship(
"Notes", order_by="Notes.row",
back_populates="playlist", lazy="joined"
)
tracks = association_proxy('playlist_tracks', 'tracks')
row = association_proxy('playlist_tracks', 'row')
def __init__(self, session: Session, name: str) -> None:
self.name = name
session.add(self)
session.flush()
def __repr__(self) -> str:
return f"<Playlists(id={self.id}, name={self.name}>"
def add_note(self, session: Session, row: int, text: str) -> Notes:
"""Add note to playlist at passed row"""
return Notes(session, self.id, row, text)
def add_track(
self, session: Session, track_id: int,
row: Optional[int] = None) -> None:
"""
Add track to playlist at given row.
If row=None, add to end of playlist
"""
if not row:
row = PlaylistTracks.next_free_row(session, self.id)
PlaylistTracks(session, self.id, track_id, row)
def close(self, session: Session) -> None:
"""Record playlist as no longer loaded"""
self.loaded = False
session.add(self)
session.flush()
@classmethod
def get_all(cls, session: Session) -> List["Playlists"]:
"""Returns a list of all playlists ordered by last use"""
return (
session.query(cls).order_by(cls.last_used.desc())
).all()
@classmethod
def get_by_id(cls, session: Session, playlist_id: int) -> "Playlists":
return (session.query(cls).filter(cls.id == playlist_id)).one()
@classmethod
def get_closed(cls, session: Session) -> List["Playlists"]:
"""Returns a list of all closed playlists ordered by last use"""
return (
session.query(cls)
.filter(cls.loaded.is_(False))
.order_by(cls.last_used.desc())
).all()
@classmethod
def get_open(cls, session: Session) -> List["Playlists"]:
"""
Return a list of playlists marked "loaded", ordered by loaded date.
"""
return (
session.query(cls)
.filter(cls.loaded.is_(True))
.order_by(cls.last_used.desc())
).all()
def mark_open(self, session: Session) -> None:
"""Mark playlist as loaded and used now"""
self.loaded = True
self.last_used = datetime.now()
session.flush()
def move_track(
self, session: Session, rows: List[int],
to_playlist: "Playlists") -> None:
"""Move tracks to another playlist"""
for row in rows:
track = self.tracks[row]
to_playlist.add_track(session, track.id)
del self.tracks[row]
session.flush()
def remove_all_tracks(self, session: Session) -> None:
"""
Remove all tracks from this playlist
"""
self.tracks = {}
session.flush()
def remove_track(self, session: Session, row: int) -> None:
DEBUG(f"Playlist.remove_track({self.id=}, {row=})")
# Get tracks collection for this playlist
tracks_collections = self.tracks
# Tracks are a dictionary of tracks keyed on row
# number. Remove the relevant row.
del tracks_collections[row]
# Save the new tracks collection
self.tracks = tracks_collections
session.flush()
class PlaylistTracks(Base):
__tablename__ = 'playlist_tracks'
id: int = Column(Integer, primary_key=True, autoincrement=True)
playlist_id: int = Column(Integer, ForeignKey('playlists.id'),
primary_key=True)
track_id: int = Column(Integer, ForeignKey('tracks.id'), primary_key=True)
row: int = Column(Integer, nullable=False)
tracks: RelationshipProperty = relationship("Tracks")
playlist: RelationshipProperty = relationship(
Playlists,
backref=backref(
"playlist_tracks",
collection_class=attribute_mapped_collection("row"),
lazy="joined",
cascade="all, delete-orphan"
)
)
# Ensure row numbers are unique within each playlist
__table_args__ = (UniqueConstraint
('row', 'playlist_id', name="uniquerow"),
)
def __init__(
self, session: Session, playlist_id: int, track_id: int,
row: int) -> None:
DEBUG(f"PlaylistTracks.__init__({playlist_id=}, {track_id=}, {row=})")
self.playlist_id = playlist_id
self.track_id = track_id
self.row = row
session.add(self)
session.flush()
@staticmethod
def next_free_row(session: Session, playlist_id: int) -> int:
"""Return next free row number"""
row: int
last_row = session.query(
func.max(PlaylistTracks.row)
).filter_by(playlist_id=playlist_id).first()
# if there are no rows, the above returns (None, ) which is True
if last_row and last_row[0] is not None:
row = last_row[0] + 1
else:
row = 0
return row
@staticmethod
def move_rows(
session: Session, rows: List[int], from_playlist_id: int,
to_playlist_id: int) -> None:
"""Move rows between playlists"""
# A constraint deliberately blocks duplicate (playlist_id, row)
# entries in database; however, unallocated rows in the database
# are fine (ie, we can have rows 1, 4, 6 and no 2, 3, 5).
# Unallocated rows will be automatically removed when the
# playlist is saved.
lowest_source_row: int = min(rows)
first_destination_free_row = PlaylistTracks.next_free_row(
session, to_playlist_id)
# Calculate offset that will put the lowest row number being
# moved at the first free row in destination playlist
offset = first_destination_free_row - lowest_source_row
session.query(PlaylistTracks).filter(
PlaylistTracks.playlist_id == from_playlist_id,
PlaylistTracks.row.in_(rows)
).update({'playlist_id': to_playlist_id,
'row': PlaylistTracks.row + offset},
False
)
class Settings(Base):
__tablename__ = 'settings'
id: int = Column(Integer, primary_key=True, autoincrement=True)
name: str = Column(String(32), nullable=False, unique=True)
f_datetime: datetime = Column(DateTime, default=None, nullable=True)
f_int: int = Column(Integer, default=None, nullable=True)
f_string: str = Column(String(128), default=None, nullable=True)
@classmethod
def get_int_settings(cls, session: Session, name: str) -> "Settings":
"""Get setting for an integer or return new setting record"""
int_setting: Settings
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.flush()
return int_setting
def update(self, session: Session, data):
for key, value in data.items():
assert hasattr(self, key)
setattr(self, key, value)
session.flush()
class Tracks(Base):
__tablename__ = 'tracks'
id: int = Column(Integer, primary_key=True, autoincrement=True)
title: str = Column(String(256), index=True)
artist: str = Column(String(256), index=True)
duration: int = Column(Integer, index=True)
start_gap: int = Column(Integer, index=False)
fade_at: int = Column(Integer, index=False)
silence_at: int = Column(Integer, index=False)
path: str = Column(String(2048), index=False, nullable=False)
mtime: float = Column(Float, index=True)
lastplayed: datetime = Column(DateTime, index=True, default=None)
playlists: RelationshipProperty = relationship("PlaylistTracks",
back_populates="tracks",
lazy="joined")
playdates: RelationshipProperty = relationship("Playdates",
back_populates="tracks",
lazy="joined")
def __init__(
self,
session: Session,
path: str,
title: Optional[str] = None,
artist: Optional[str] = None,
duration: Optional[int] = None,
start_gap: Optional[int] = None,
fade_at: Optional[int] = None,
silence_at: Optional[int] = None,
mtime: Optional[float] = None,
lastplayed: Optional[datetime] = None,
) -> None:
self.path = path
self.title = title
self.artist = artist
self.duration = duration
self.start_gap = start_gap
self.fade_at = fade_at
self.silence_at = silence_at
self.mtime = mtime
self.lastplayed = lastplayed
session.add(self)
session.flush()
def __repr__(self) -> str:
return (
f"<Track(id={self.id}, title={self.title}, "
f"artist={self.artist}, path={self.path}>"
)
@staticmethod
def get_all_paths(session) -> List[str]:
"""Return a list of paths of all tracks"""
return [a[0] for a in session.query(Tracks.path).all()]
@classmethod
def get_all_tracks(cls, session: Session) -> List["Tracks"]:
"""Return a list of all tracks"""
return session.query(cls).all()
@classmethod
def get_or_create(cls, session: Session, path: str) -> "Tracks":
"""
If a track with path exists, return it;
else created new track and return it
"""
DEBUG(f"Tracks.get_or_create({path=})")
try:
track = session.query(cls).filter(cls.path == path).one()
except NoResultFound:
track = Tracks(session, path)
return track
@classmethod
def get_by_filename(cls, session: Session, filename: str) \
-> Optional["Tracks"]:
"""
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
@classmethod
def get_by_path(cls, session: Session, path: str) -> List["Tracks"]:
"""
Return track with passee path, or None.
"""
DEBUG(f"Tracks.get_track_from_path({path=})")
return session.query(Tracks).filter(Tracks.path == path).first()
@classmethod
def get_by_id(cls, session: Session, track_id: int) -> Optional["Tracks"]:
"""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
def rescan(self, session: Session) -> None:
"""
Update audio metadata for passed track.
"""
audio: AudioSegment = get_audio_segment(self.path)
self.duration = len(audio)
self.fade_at = round(fade_point(audio) / 1000,
Config.MILLISECOND_SIGFIGS) * 1000
self.mtime = os.path.getmtime(self.path)
self.silence_at = round(trailing_silence(audio) / 1000,
Config.MILLISECOND_SIGFIGS) * 1000
self.start_gap = leading_silence(audio)
session.add(self)
session.flush()
@staticmethod
def remove_by_path(session: Session, path: str) -> None:
"""Remove track with passed path from database"""
DEBUG(f"Tracks.remove_path({path=})")
try:
session.query(Tracks).filter(Tracks.path == path).delete()
session.flush()
except IntegrityError as exception:
ERROR(f"Can't remove track with {path=} ({exception=})")
@classmethod
def search_artists(cls, session: Session, text: str) -> List["Tracks"]:
return (
session.query(cls)
.filter(cls.artist.ilike(f"%{text}%"))
.order_by(cls.title)
).all()
@classmethod
def search_titles(cls, session: Session, text: str) -> List["Tracks"]:
return (
session.query(cls)
.filter(cls.title.ilike(f"%{text}%"))
.order_by(cls.title)
).all()
@staticmethod
def update_lastplayed(session: Session, track_id: int) -> None:
"""Update the last_played field to current datetime"""
rec = session.query(Tracks).get(track_id)
rec.lastplayed = datetime.now()
session.add(rec)
session.flush()
def update_artist(self, session: Session, artist: str) -> None:
self.artist = artist
session.add(self)
session.flush()
def update_title(self, session: Session, title: str) -> None:
self.title = title
session.add(self)
session.flush()
def update_path(self, newpath: str) -> None:
self.path = newpath

View File

@ -81,18 +81,16 @@ class Music:
sleep(sleep_time) sleep(sleep_time)
with lock: with lock:
DEBUG(f"music._facde(), stopping {p=}", True) DEBUG(f"music._fade(), stopping {p=}", True)
p.stop() p.stop()
DEBUG(f"Releasing player {p=}", True) DEBUG(f"Releasing player {p=}", True)
p.release() p.release()
# Ensure we don't reference player after release
p = None
self.fading -= 1 self.fading -= 1
def get_playtime(self): def get_playtime(self):
"Return elapsed play time" """Return elapsed play time"""
with lock: with lock:
if not self.player: if not self.player:
@ -101,11 +99,14 @@ class Music:
return self.player.get_time() return self.player.get_time()
def get_position(self): def get_position(self):
"Return current position" """Return current position"""
with lock: with lock:
DEBUG("music.get_position", True) DEBUG("music.get_position", True)
print(f"get_position, {self.player=}")
if not self.player:
return
return self.player.get_position() return self.player.get_position()
def play(self, path): def play(self, path):
@ -147,13 +148,13 @@ class Music:
return self.fading > 0 return self.fading > 0
def set_position(self, ms): def set_position(self, ms):
"Set current play time in milliseconds from start" """Set current play time in milliseconds from start"""
with lock: with lock:
return self.player.set_time(ms) return self.player.set_time(ms)
def set_volume(self, volume): def set_volume(self, volume):
"Set maximum volume used for player" """Set maximum volume used for player"""
with lock: with lock:
if not self.player: if not self.player:
@ -163,7 +164,7 @@ class Music:
self.player.audio_set_volume(volume) self.player.audio_set_volume(volume)
def stop(self): def stop(self):
"Immediately stop playing" """Immediately stop playing"""
DEBUG(f"music.stop(), {self.player=}", True) DEBUG(f"music.stop(), {self.player=}", True)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -6,7 +6,7 @@
<rect> <rect>
<x>0</x> <x>0</x>
<y>0</y> <y>0</y>
<width>1164</width> <width>1280</width>
<height>857</height> <height>857</height>
</rect> </rect>
</property> </property>
@ -17,7 +17,7 @@
<string notr="true"/> <string notr="true"/>
</property> </property>
<widget class="QWidget" name="centralwidget"> <widget class="QWidget" name="centralwidget">
<layout class="QGridLayout" name="gridLayout_3"> <layout class="QGridLayout" name="gridLayout_6">
<item row="0" column="0"> <item row="0" column="0">
<layout class="QHBoxLayout" name="horizontalLayout_3"> <layout class="QHBoxLayout" name="horizontalLayout_3">
<item> <item>
@ -175,7 +175,7 @@ border: 1px solid rgb(85, 87, 83);</string>
</widget> </widget>
</item> </item>
<item> <item>
<widget class="ElideLabel" name="hdrNextTrack"> <widget class="QLabel" name="hdrNextTrack">
<property name="minimumSize"> <property name="minimumSize">
<size> <size>
<width>0</width> <width>0</width>
@ -337,6 +337,9 @@ border: 1px solid rgb(85, 87, 83);</string>
</item> </item>
<item> <item>
<widget class="QPushButton" name="btnAddFile"> <widget class="QPushButton" name="btnAddFile">
<property name="enabled">
<bool>false</bool>
</property>
<property name="text"> <property name="text">
<string>Add file</string> <string>Add file</string>
</property> </property>
@ -485,11 +488,11 @@ border: 1px solid rgb(85, 87, 83);</string>
<property name="frameShadow"> <property name="frameShadow">
<enum>QFrame::Raised</enum> <enum>QFrame::Raised</enum>
</property> </property>
<layout class="QGridLayout" name="gridLayout"> <layout class="QFormLayout" name="formLayout">
<item row="0" column="0"> <item row="0" column="0">
<widget class="QLabel" name="label_2"> <widget class="QLabel" name="label_x">
<property name="text"> <property name="text">
<string>Started at:</string> <string>Track length:</string>
</property> </property>
<property name="alignment"> <property name="alignment">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set> <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
@ -497,7 +500,7 @@ border: 1px solid rgb(85, 87, 83);</string>
</widget> </widget>
</item> </item>
<item row="0" column="1"> <item row="0" column="1">
<widget class="QLabel" name="label_start_tod"> <widget class="QLabel" name="label_track_length">
<property name="font"> <property name="font">
<font> <font>
<family>FreeSans</family> <family>FreeSans</family>
@ -505,7 +508,7 @@ border: 1px solid rgb(85, 87, 83);</string>
</font> </font>
</property> </property>
<property name="text"> <property name="text">
<string>00:00:00</string> <string>0:00</string>
</property> </property>
<property name="scaledContents"> <property name="scaledContents">
<bool>false</bool> <bool>false</bool>
@ -513,32 +516,6 @@ border: 1px solid rgb(85, 87, 83);</string>
</widget> </widget>
</item> </item>
<item row="1" column="0"> <item row="1" column="0">
<widget class="QLabel" name="label_3">
<property name="text">
<string>Silent at:</string>
</property>
<property name="alignment">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QLabel" name="label_silent_tod">
<property name="font">
<font>
<family>FreeSans</family>
<pointsize>16</pointsize>
</font>
</property>
<property name="text">
<string>00:00:00</string>
</property>
<property name="scaledContents">
<bool>false</bool>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QLabel" name="label_7"> <widget class="QLabel" name="label_7">
<property name="text"> <property name="text">
<string>Fade length:</string> <string>Fade length:</string>
@ -548,7 +525,7 @@ border: 1px solid rgb(85, 87, 83);</string>
</property> </property>
</widget> </widget>
</item> </item>
<item row="2" column="1"> <item row="1" column="1">
<widget class="QLabel" name="label_fade_length"> <widget class="QLabel" name="label_fade_length">
<property name="font"> <property name="font">
<font> <font>
@ -564,6 +541,32 @@ border: 1px solid rgb(85, 87, 83);</string>
</property> </property>
</widget> </widget>
</item> </item>
<item row="2" column="0">
<widget class="QLabel" name="label_3">
<property name="text">
<string>Silence length:</string>
</property>
<property name="alignment">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QLabel" name="label_silence_length">
<property name="font">
<font>
<family>FreeSans</family>
<pointsize>16</pointsize>
</font>
</property>
<property name="text">
<string>0:00</string>
</property>
<property name="scaledContents">
<bool>false</bool>
</property>
</widget>
</item>
</layout> </layout>
</widget> </widget>
</item> </item>
@ -578,8 +581,8 @@ border: 1px solid rgb(85, 87, 83);</string>
<property name="frameShadow"> <property name="frameShadow">
<enum>QFrame::Raised</enum> <enum>QFrame::Raised</enum>
</property> </property>
<layout class="QVBoxLayout" name="verticalLayout_5"> <layout class="QGridLayout" name="gridLayout">
<item> <item row="0" column="0">
<widget class="QLabel" name="label"> <widget class="QLabel" name="label">
<property name="text"> <property name="text">
<string>Elapsed time</string> <string>Elapsed time</string>
@ -589,7 +592,7 @@ border: 1px solid rgb(85, 87, 83);</string>
</property> </property>
</widget> </widget>
</item> </item>
<item> <item row="1" column="0">
<widget class="QLabel" name="label_elapsed_timer"> <widget class="QLabel" name="label_elapsed_timer">
<property name="font"> <property name="font">
<font> <font>
@ -621,8 +624,8 @@ border: 1px solid rgb(85, 87, 83);</string>
<property name="frameShadow"> <property name="frameShadow">
<enum>QFrame::Raised</enum> <enum>QFrame::Raised</enum>
</property> </property>
<layout class="QVBoxLayout" name="verticalLayout_6"> <layout class="QGridLayout" name="gridLayout_3">
<item> <item row="0" column="0">
<widget class="QLabel" name="label_4"> <widget class="QLabel" name="label_4">
<property name="text"> <property name="text">
<string>Fade</string> <string>Fade</string>
@ -632,7 +635,7 @@ border: 1px solid rgb(85, 87, 83);</string>
</property> </property>
</widget> </widget>
</item> </item>
<item> <item row="1" column="0">
<widget class="QLabel" name="label_fade_timer"> <widget class="QLabel" name="label_fade_timer">
<property name="font"> <property name="font">
<font> <font>
@ -664,8 +667,8 @@ border: 1px solid rgb(85, 87, 83);</string>
<property name="frameShadow"> <property name="frameShadow">
<enum>QFrame::Raised</enum> <enum>QFrame::Raised</enum>
</property> </property>
<layout class="QVBoxLayout" name="verticalLayout_7"> <layout class="QGridLayout" name="gridLayout_4">
<item> <item row="0" column="0">
<widget class="QLabel" name="label_5"> <widget class="QLabel" name="label_5">
<property name="text"> <property name="text">
<string>Silent</string> <string>Silent</string>
@ -675,7 +678,7 @@ border: 1px solid rgb(85, 87, 83);</string>
</property> </property>
</widget> </widget>
</item> </item>
<item> <item row="1" column="0">
<widget class="QLabel" name="label_silent_timer"> <widget class="QLabel" name="label_silent_timer">
<property name="font"> <property name="font">
<font> <font>
@ -707,8 +710,8 @@ border: 1px solid rgb(85, 87, 83);</string>
<property name="frameShadow"> <property name="frameShadow">
<enum>QFrame::Raised</enum> <enum>QFrame::Raised</enum>
</property> </property>
<layout class="QVBoxLayout" name="verticalLayout_8"> <layout class="QGridLayout" name="gridLayout_5">
<item> <item row="0" column="0">
<widget class="QLabel" name="label_6"> <widget class="QLabel" name="label_6">
<property name="text"> <property name="text">
<string>End</string> <string>End</string>
@ -718,7 +721,7 @@ border: 1px solid rgb(85, 87, 83);</string>
</property> </property>
</widget> </widget>
</item> </item>
<item> <item row="1" column="0">
<widget class="QLabel" name="label_end_timer"> <widget class="QLabel" name="label_end_timer">
<property name="font"> <property name="font">
<font> <font>
@ -748,15 +751,18 @@ border: 1px solid rgb(85, 87, 83);</string>
<rect> <rect>
<x>0</x> <x>0</x>
<y>0</y> <y>0</y>
<width>1164</width> <width>1280</width>
<height>29</height> <height>24</height>
</rect> </rect>
</property> </property>
<widget class="QMenu" name="menuFile"> <widget class="QMenu" name="menuFile">
<property name="title"> <property name="title">
<string>Fi&amp;le</string> <string>Fi&amp;le</string>
</property> </property>
<addaction name="actionImport"/>
<addaction name="separator"/>
<addaction name="actionE_xit"/> <addaction name="actionE_xit"/>
<addaction name="separator"/>
</widget> </widget>
<widget class="QMenu" name="menuPlaylist"> <widget class="QMenu" name="menuPlaylist">
<property name="title"> <property name="title">
@ -769,22 +775,22 @@ border: 1px solid rgb(85, 87, 83);</string>
<addaction name="actionDeletePlaylist"/> <addaction name="actionDeletePlaylist"/>
<addaction name="separator"/> <addaction name="separator"/>
<addaction name="actionSearch_database"/> <addaction name="actionSearch_database"/>
<addaction name="actionAdd_file"/>
<addaction name="actionAdd_note"/> <addaction name="actionAdd_note"/>
<addaction name="action_Clear_selection"/> <addaction name="action_Clear_selection"/>
<addaction name="separator"/> <addaction name="separator"/>
<addaction name="actionSelect_previous_track"/>
<addaction name="actionSelect_next_track"/>
<addaction name="actionSetNext"/>
<addaction name="separator"/>
<addaction name="actionSelect_unplayed_tracks"/> <addaction name="actionSelect_unplayed_tracks"/>
<addaction name="actionSelect_played_tracks"/> <addaction name="actionSelect_played_tracks"/>
<addaction name="actionMoveSelected"/> <addaction name="actionMoveSelected"/>
<addaction name="separator"/> <addaction name="separator"/>
<addaction name="actionExport_playlist"/> <addaction name="actionExport_playlist"/>
<addaction name="separator"/>
<addaction name="actionSelect_next_track"/>
<addaction name="actionSelect_previous_track"/>
</widget> </widget>
<widget class="QMenu" name="menu_Tracks"> <widget class="QMenu" name="menu_Music">
<property name="title"> <property name="title">
<string>&amp;Tracks</string> <string>&amp;Music</string>
</property> </property>
<addaction name="actionPlay_next"/> <addaction name="actionPlay_next"/>
<addaction name="actionSkip_next"/> <addaction name="actionSkip_next"/>
@ -792,21 +798,11 @@ border: 1px solid rgb(85, 87, 83);</string>
<addaction name="actionStop"/> <addaction name="actionStop"/>
<addaction name="action_Resume_previous"/> <addaction name="action_Resume_previous"/>
<addaction name="separator"/> <addaction name="separator"/>
<addaction name="actionSetNext"/> <addaction name="actionEnable_controls"/>
</widget>
<widget class="QMenu" name="menuTest">
<property name="title">
<string>TestMo&amp;de</string>
</property>
<addaction name="actionTestFunction"/>
<addaction name="separator"/>
<addaction name="actionSkipToFade"/>
<addaction name="actionSkipToEnd"/>
</widget> </widget>
<addaction name="menuFile"/> <addaction name="menuFile"/>
<addaction name="menuPlaylist"/> <addaction name="menuPlaylist"/>
<addaction name="menu_Tracks"/> <addaction name="menu_Music"/>
<addaction name="menuTest"/>
</widget> </widget>
<widget class="QStatusBar" name="statusbar"> <widget class="QStatusBar" name="statusbar">
<property name="enabled"> <property name="enabled">
@ -1013,14 +1009,20 @@ border: 1px solid rgb(85, 87, 83);</string>
<string>Ctrl+T</string> <string>Ctrl+T</string>
</property> </property>
</action> </action>
<action name="actionEnable_controls">
<property name="text">
<string>Enable controls</string>
</property>
</action>
<action name="actionImport">
<property name="text">
<string>Import...</string>
</property>
<property name="shortcut">
<string>Ctrl+Shift+I</string>
</property>
</action>
</widget> </widget>
<customwidgets>
<customwidget>
<class>ElideLabel</class>
<extends>QLabel</extends>
<header>musicmuster</header>
</customwidget>
</customwidgets>
<resources> <resources>
<include location="icons.qrc"/> <include location="icons.qrc"/>
</resources> </resources>

View File

@ -2,7 +2,7 @@
# Form implementation generated from reading ui file 'ui/main_window.ui' # Form implementation generated from reading ui file 'ui/main_window.ui'
# #
# Created by: PyQt5 UI code generator 5.15.4 # Created by: PyQt5 UI code generator 5.15.6
# #
# WARNING: Any manual changes made to this file will be lost when pyuic5 is # 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. # run again. Do not edit this file unless you know what you are doing.
@ -14,12 +14,12 @@ from PyQt5 import QtCore, QtGui, QtWidgets
class Ui_MainWindow(object): class Ui_MainWindow(object):
def setupUi(self, MainWindow): def setupUi(self, MainWindow):
MainWindow.setObjectName("MainWindow") MainWindow.setObjectName("MainWindow")
MainWindow.resize(1164, 857) MainWindow.resize(1280, 857)
MainWindow.setStyleSheet("") MainWindow.setStyleSheet("")
self.centralwidget = QtWidgets.QWidget(MainWindow) self.centralwidget = QtWidgets.QWidget(MainWindow)
self.centralwidget.setObjectName("centralwidget") self.centralwidget.setObjectName("centralwidget")
self.gridLayout_3 = QtWidgets.QGridLayout(self.centralwidget) self.gridLayout_6 = QtWidgets.QGridLayout(self.centralwidget)
self.gridLayout_3.setObjectName("gridLayout_3") self.gridLayout_6.setObjectName("gridLayout_6")
self.horizontalLayout_3 = QtWidgets.QHBoxLayout() self.horizontalLayout_3 = QtWidgets.QHBoxLayout()
self.horizontalLayout_3.setObjectName("horizontalLayout_3") self.horizontalLayout_3.setObjectName("horizontalLayout_3")
self.verticalLayout_3 = QtWidgets.QVBoxLayout() self.verticalLayout_3 = QtWidgets.QVBoxLayout()
@ -99,7 +99,7 @@ class Ui_MainWindow(object):
self.hdrCurrentTrack.setWordWrap(True) self.hdrCurrentTrack.setWordWrap(True)
self.hdrCurrentTrack.setObjectName("hdrCurrentTrack") self.hdrCurrentTrack.setObjectName("hdrCurrentTrack")
self.verticalLayout.addWidget(self.hdrCurrentTrack) self.verticalLayout.addWidget(self.hdrCurrentTrack)
self.hdrNextTrack = ElideLabel(self.centralwidget) self.hdrNextTrack = QtWidgets.QLabel(self.centralwidget)
self.hdrNextTrack.setMinimumSize(QtCore.QSize(0, 39)) self.hdrNextTrack.setMinimumSize(QtCore.QSize(0, 39))
self.hdrNextTrack.setMaximumSize(QtCore.QSize(16777215, 39)) self.hdrNextTrack.setMaximumSize(QtCore.QSize(16777215, 39))
font = QtGui.QFont() font = QtGui.QFont()
@ -129,7 +129,7 @@ class Ui_MainWindow(object):
self.lblTOD.setObjectName("lblTOD") self.lblTOD.setObjectName("lblTOD")
self.gridLayout_2.addWidget(self.lblTOD, 0, 0, 1, 1) self.gridLayout_2.addWidget(self.lblTOD, 0, 0, 1, 1)
self.horizontalLayout_3.addWidget(self.frame_2) self.horizontalLayout_3.addWidget(self.frame_2)
self.gridLayout_3.addLayout(self.horizontalLayout_3, 0, 0, 1, 1) self.gridLayout_6.addLayout(self.horizontalLayout_3, 0, 0, 1, 1)
self.frame_5 = QtWidgets.QFrame(self.centralwidget) self.frame_5 = QtWidgets.QFrame(self.centralwidget)
self.frame_5.setFrameShape(QtWidgets.QFrame.StyledPanel) self.frame_5.setFrameShape(QtWidgets.QFrame.StyledPanel)
self.frame_5.setFrameShadow(QtWidgets.QFrame.Raised) self.frame_5.setFrameShadow(QtWidgets.QFrame.Raised)
@ -162,6 +162,7 @@ class Ui_MainWindow(object):
self.btnDatabase.setObjectName("btnDatabase") self.btnDatabase.setObjectName("btnDatabase")
self.horizontalLayout.addWidget(self.btnDatabase) self.horizontalLayout.addWidget(self.btnDatabase)
self.btnAddFile = QtWidgets.QPushButton(self.frame_5) self.btnAddFile = QtWidgets.QPushButton(self.frame_5)
self.btnAddFile.setEnabled(False)
icon3 = QtGui.QIcon() icon3 = QtGui.QIcon()
icon3.addPixmap(QtGui.QPixmap(":/icons/open_file"), QtGui.QIcon.Normal, QtGui.QIcon.Off) icon3.addPixmap(QtGui.QPixmap(":/icons/open_file"), QtGui.QIcon.Normal, QtGui.QIcon.Off)
self.btnAddFile.setIcon(icon3) self.btnAddFile.setIcon(icon3)
@ -205,49 +206,37 @@ class Ui_MainWindow(object):
self.spnVolume.setProperty("value", 100) self.spnVolume.setProperty("value", 100)
self.spnVolume.setObjectName("spnVolume") self.spnVolume.setObjectName("spnVolume")
self.horizontalLayout.addWidget(self.spnVolume) self.horizontalLayout.addWidget(self.spnVolume)
self.gridLayout_3.addWidget(self.frame_5, 1, 0, 1, 1) self.gridLayout_6.addWidget(self.frame_5, 1, 0, 1, 1)
self.tabPlaylist = QtWidgets.QTabWidget(self.centralwidget) self.tabPlaylist = QtWidgets.QTabWidget(self.centralwidget)
self.tabPlaylist.setDocumentMode(False) self.tabPlaylist.setDocumentMode(False)
self.tabPlaylist.setTabsClosable(True) self.tabPlaylist.setTabsClosable(True)
self.tabPlaylist.setMovable(True) self.tabPlaylist.setMovable(True)
self.tabPlaylist.setObjectName("tabPlaylist") self.tabPlaylist.setObjectName("tabPlaylist")
self.gridLayout_3.addWidget(self.tabPlaylist, 2, 0, 1, 1) self.gridLayout_6.addWidget(self.tabPlaylist, 2, 0, 1, 1)
self.horizontalLayout_2 = QtWidgets.QHBoxLayout() self.horizontalLayout_2 = QtWidgets.QHBoxLayout()
self.horizontalLayout_2.setObjectName("horizontalLayout_2") self.horizontalLayout_2.setObjectName("horizontalLayout_2")
self.frame = QtWidgets.QFrame(self.centralwidget) self.frame = QtWidgets.QFrame(self.centralwidget)
self.frame.setFrameShape(QtWidgets.QFrame.StyledPanel) self.frame.setFrameShape(QtWidgets.QFrame.StyledPanel)
self.frame.setFrameShadow(QtWidgets.QFrame.Raised) self.frame.setFrameShadow(QtWidgets.QFrame.Raised)
self.frame.setObjectName("frame") self.frame.setObjectName("frame")
self.gridLayout = QtWidgets.QGridLayout(self.frame) self.formLayout = QtWidgets.QFormLayout(self.frame)
self.gridLayout.setObjectName("gridLayout") self.formLayout.setObjectName("formLayout")
self.label_2 = QtWidgets.QLabel(self.frame) self.label_x = QtWidgets.QLabel(self.frame)
self.label_2.setAlignment(QtCore.Qt.AlignRight|QtCore.Qt.AlignTrailing|QtCore.Qt.AlignVCenter) self.label_x.setAlignment(QtCore.Qt.AlignRight|QtCore.Qt.AlignTrailing|QtCore.Qt.AlignVCenter)
self.label_2.setObjectName("label_2") self.label_x.setObjectName("label_x")
self.gridLayout.addWidget(self.label_2, 0, 0, 1, 1) self.formLayout.setWidget(0, QtWidgets.QFormLayout.LabelRole, self.label_x)
self.label_start_tod = QtWidgets.QLabel(self.frame) self.label_track_length = QtWidgets.QLabel(self.frame)
font = QtGui.QFont() font = QtGui.QFont()
font.setFamily("FreeSans") font.setFamily("FreeSans")
font.setPointSize(16) font.setPointSize(16)
self.label_start_tod.setFont(font) self.label_track_length.setFont(font)
self.label_start_tod.setScaledContents(False) self.label_track_length.setScaledContents(False)
self.label_start_tod.setObjectName("label_start_tod") self.label_track_length.setObjectName("label_track_length")
self.gridLayout.addWidget(self.label_start_tod, 0, 1, 1, 1) self.formLayout.setWidget(0, QtWidgets.QFormLayout.FieldRole, self.label_track_length)
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 = QtWidgets.QLabel(self.frame)
self.label_7.setAlignment(QtCore.Qt.AlignRight|QtCore.Qt.AlignTrailing|QtCore.Qt.AlignVCenter) self.label_7.setAlignment(QtCore.Qt.AlignRight|QtCore.Qt.AlignTrailing|QtCore.Qt.AlignVCenter)
self.label_7.setObjectName("label_7") self.label_7.setObjectName("label_7")
self.gridLayout.addWidget(self.label_7, 2, 0, 1, 1) self.formLayout.setWidget(1, QtWidgets.QFormLayout.LabelRole, self.label_7)
self.label_fade_length = QtWidgets.QLabel(self.frame) self.label_fade_length = QtWidgets.QLabel(self.frame)
font = QtGui.QFont() font = QtGui.QFont()
font.setFamily("FreeSans") font.setFamily("FreeSans")
@ -255,19 +244,31 @@ class Ui_MainWindow(object):
self.label_fade_length.setFont(font) self.label_fade_length.setFont(font)
self.label_fade_length.setScaledContents(False) self.label_fade_length.setScaledContents(False)
self.label_fade_length.setObjectName("label_fade_length") self.label_fade_length.setObjectName("label_fade_length")
self.gridLayout.addWidget(self.label_fade_length, 2, 1, 1, 1) self.formLayout.setWidget(1, QtWidgets.QFormLayout.FieldRole, self.label_fade_length)
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.formLayout.setWidget(2, QtWidgets.QFormLayout.LabelRole, self.label_3)
self.label_silence_length = QtWidgets.QLabel(self.frame)
font = QtGui.QFont()
font.setFamily("FreeSans")
font.setPointSize(16)
self.label_silence_length.setFont(font)
self.label_silence_length.setScaledContents(False)
self.label_silence_length.setObjectName("label_silence_length")
self.formLayout.setWidget(2, QtWidgets.QFormLayout.FieldRole, self.label_silence_length)
self.horizontalLayout_2.addWidget(self.frame) self.horizontalLayout_2.addWidget(self.frame)
self.frame_elapsed = QtWidgets.QFrame(self.centralwidget) self.frame_elapsed = QtWidgets.QFrame(self.centralwidget)
self.frame_elapsed.setStyleSheet("") self.frame_elapsed.setStyleSheet("")
self.frame_elapsed.setFrameShape(QtWidgets.QFrame.StyledPanel) self.frame_elapsed.setFrameShape(QtWidgets.QFrame.StyledPanel)
self.frame_elapsed.setFrameShadow(QtWidgets.QFrame.Raised) self.frame_elapsed.setFrameShadow(QtWidgets.QFrame.Raised)
self.frame_elapsed.setObjectName("frame_elapsed") self.frame_elapsed.setObjectName("frame_elapsed")
self.verticalLayout_5 = QtWidgets.QVBoxLayout(self.frame_elapsed) self.gridLayout = QtWidgets.QGridLayout(self.frame_elapsed)
self.verticalLayout_5.setObjectName("verticalLayout_5") self.gridLayout.setObjectName("gridLayout")
self.label = QtWidgets.QLabel(self.frame_elapsed) self.label = QtWidgets.QLabel(self.frame_elapsed)
self.label.setAlignment(QtCore.Qt.AlignCenter) self.label.setAlignment(QtCore.Qt.AlignCenter)
self.label.setObjectName("label") self.label.setObjectName("label")
self.verticalLayout_5.addWidget(self.label) self.gridLayout.addWidget(self.label, 0, 0, 1, 1)
self.label_elapsed_timer = QtWidgets.QLabel(self.frame_elapsed) self.label_elapsed_timer = QtWidgets.QLabel(self.frame_elapsed)
font = QtGui.QFont() font = QtGui.QFont()
font.setFamily("FreeSans") font.setFamily("FreeSans")
@ -277,19 +278,19 @@ class Ui_MainWindow(object):
self.label_elapsed_timer.setFont(font) self.label_elapsed_timer.setFont(font)
self.label_elapsed_timer.setAlignment(QtCore.Qt.AlignCenter) self.label_elapsed_timer.setAlignment(QtCore.Qt.AlignCenter)
self.label_elapsed_timer.setObjectName("label_elapsed_timer") self.label_elapsed_timer.setObjectName("label_elapsed_timer")
self.verticalLayout_5.addWidget(self.label_elapsed_timer) self.gridLayout.addWidget(self.label_elapsed_timer, 1, 0, 1, 1)
self.horizontalLayout_2.addWidget(self.frame_elapsed) self.horizontalLayout_2.addWidget(self.frame_elapsed)
self.frame_fade = QtWidgets.QFrame(self.centralwidget) self.frame_fade = QtWidgets.QFrame(self.centralwidget)
self.frame_fade.setStyleSheet("") self.frame_fade.setStyleSheet("")
self.frame_fade.setFrameShape(QtWidgets.QFrame.StyledPanel) self.frame_fade.setFrameShape(QtWidgets.QFrame.StyledPanel)
self.frame_fade.setFrameShadow(QtWidgets.QFrame.Raised) self.frame_fade.setFrameShadow(QtWidgets.QFrame.Raised)
self.frame_fade.setObjectName("frame_fade") self.frame_fade.setObjectName("frame_fade")
self.verticalLayout_6 = QtWidgets.QVBoxLayout(self.frame_fade) self.gridLayout_3 = QtWidgets.QGridLayout(self.frame_fade)
self.verticalLayout_6.setObjectName("verticalLayout_6") self.gridLayout_3.setObjectName("gridLayout_3")
self.label_4 = QtWidgets.QLabel(self.frame_fade) self.label_4 = QtWidgets.QLabel(self.frame_fade)
self.label_4.setAlignment(QtCore.Qt.AlignCenter) self.label_4.setAlignment(QtCore.Qt.AlignCenter)
self.label_4.setObjectName("label_4") self.label_4.setObjectName("label_4")
self.verticalLayout_6.addWidget(self.label_4) self.gridLayout_3.addWidget(self.label_4, 0, 0, 1, 1)
self.label_fade_timer = QtWidgets.QLabel(self.frame_fade) self.label_fade_timer = QtWidgets.QLabel(self.frame_fade)
font = QtGui.QFont() font = QtGui.QFont()
font.setFamily("FreeSans") font.setFamily("FreeSans")
@ -299,19 +300,19 @@ class Ui_MainWindow(object):
self.label_fade_timer.setFont(font) self.label_fade_timer.setFont(font)
self.label_fade_timer.setAlignment(QtCore.Qt.AlignCenter) self.label_fade_timer.setAlignment(QtCore.Qt.AlignCenter)
self.label_fade_timer.setObjectName("label_fade_timer") self.label_fade_timer.setObjectName("label_fade_timer")
self.verticalLayout_6.addWidget(self.label_fade_timer) self.gridLayout_3.addWidget(self.label_fade_timer, 1, 0, 1, 1)
self.horizontalLayout_2.addWidget(self.frame_fade) self.horizontalLayout_2.addWidget(self.frame_fade)
self.frame_silent = QtWidgets.QFrame(self.centralwidget) self.frame_silent = QtWidgets.QFrame(self.centralwidget)
self.frame_silent.setStyleSheet("") self.frame_silent.setStyleSheet("")
self.frame_silent.setFrameShape(QtWidgets.QFrame.StyledPanel) self.frame_silent.setFrameShape(QtWidgets.QFrame.StyledPanel)
self.frame_silent.setFrameShadow(QtWidgets.QFrame.Raised) self.frame_silent.setFrameShadow(QtWidgets.QFrame.Raised)
self.frame_silent.setObjectName("frame_silent") self.frame_silent.setObjectName("frame_silent")
self.verticalLayout_7 = QtWidgets.QVBoxLayout(self.frame_silent) self.gridLayout_4 = QtWidgets.QGridLayout(self.frame_silent)
self.verticalLayout_7.setObjectName("verticalLayout_7") self.gridLayout_4.setObjectName("gridLayout_4")
self.label_5 = QtWidgets.QLabel(self.frame_silent) self.label_5 = QtWidgets.QLabel(self.frame_silent)
self.label_5.setAlignment(QtCore.Qt.AlignCenter) self.label_5.setAlignment(QtCore.Qt.AlignCenter)
self.label_5.setObjectName("label_5") self.label_5.setObjectName("label_5")
self.verticalLayout_7.addWidget(self.label_5) self.gridLayout_4.addWidget(self.label_5, 0, 0, 1, 1)
self.label_silent_timer = QtWidgets.QLabel(self.frame_silent) self.label_silent_timer = QtWidgets.QLabel(self.frame_silent)
font = QtGui.QFont() font = QtGui.QFont()
font.setFamily("FreeSans") font.setFamily("FreeSans")
@ -321,19 +322,19 @@ class Ui_MainWindow(object):
self.label_silent_timer.setFont(font) self.label_silent_timer.setFont(font)
self.label_silent_timer.setAlignment(QtCore.Qt.AlignCenter) self.label_silent_timer.setAlignment(QtCore.Qt.AlignCenter)
self.label_silent_timer.setObjectName("label_silent_timer") self.label_silent_timer.setObjectName("label_silent_timer")
self.verticalLayout_7.addWidget(self.label_silent_timer) self.gridLayout_4.addWidget(self.label_silent_timer, 1, 0, 1, 1)
self.horizontalLayout_2.addWidget(self.frame_silent) self.horizontalLayout_2.addWidget(self.frame_silent)
self.frame_end = QtWidgets.QFrame(self.centralwidget) self.frame_end = QtWidgets.QFrame(self.centralwidget)
self.frame_end.setStyleSheet("") self.frame_end.setStyleSheet("")
self.frame_end.setFrameShape(QtWidgets.QFrame.StyledPanel) self.frame_end.setFrameShape(QtWidgets.QFrame.StyledPanel)
self.frame_end.setFrameShadow(QtWidgets.QFrame.Raised) self.frame_end.setFrameShadow(QtWidgets.QFrame.Raised)
self.frame_end.setObjectName("frame_end") self.frame_end.setObjectName("frame_end")
self.verticalLayout_8 = QtWidgets.QVBoxLayout(self.frame_end) self.gridLayout_5 = QtWidgets.QGridLayout(self.frame_end)
self.verticalLayout_8.setObjectName("verticalLayout_8") self.gridLayout_5.setObjectName("gridLayout_5")
self.label_6 = QtWidgets.QLabel(self.frame_end) self.label_6 = QtWidgets.QLabel(self.frame_end)
self.label_6.setAlignment(QtCore.Qt.AlignCenter) self.label_6.setAlignment(QtCore.Qt.AlignCenter)
self.label_6.setObjectName("label_6") self.label_6.setObjectName("label_6")
self.verticalLayout_8.addWidget(self.label_6) self.gridLayout_5.addWidget(self.label_6, 0, 0, 1, 1)
self.label_end_timer = QtWidgets.QLabel(self.frame_end) self.label_end_timer = QtWidgets.QLabel(self.frame_end)
font = QtGui.QFont() font = QtGui.QFont()
font.setFamily("FreeSans") font.setFamily("FreeSans")
@ -343,21 +344,19 @@ class Ui_MainWindow(object):
self.label_end_timer.setFont(font) self.label_end_timer.setFont(font)
self.label_end_timer.setAlignment(QtCore.Qt.AlignCenter) self.label_end_timer.setAlignment(QtCore.Qt.AlignCenter)
self.label_end_timer.setObjectName("label_end_timer") self.label_end_timer.setObjectName("label_end_timer")
self.verticalLayout_8.addWidget(self.label_end_timer) self.gridLayout_5.addWidget(self.label_end_timer, 1, 0, 1, 1)
self.horizontalLayout_2.addWidget(self.frame_end) self.horizontalLayout_2.addWidget(self.frame_end)
self.gridLayout_3.addLayout(self.horizontalLayout_2, 3, 0, 1, 1) self.gridLayout_6.addLayout(self.horizontalLayout_2, 3, 0, 1, 1)
MainWindow.setCentralWidget(self.centralwidget) MainWindow.setCentralWidget(self.centralwidget)
self.menubar = QtWidgets.QMenuBar(MainWindow) self.menubar = QtWidgets.QMenuBar(MainWindow)
self.menubar.setGeometry(QtCore.QRect(0, 0, 1164, 29)) self.menubar.setGeometry(QtCore.QRect(0, 0, 1280, 24))
self.menubar.setObjectName("menubar") self.menubar.setObjectName("menubar")
self.menuFile = QtWidgets.QMenu(self.menubar) self.menuFile = QtWidgets.QMenu(self.menubar)
self.menuFile.setObjectName("menuFile") self.menuFile.setObjectName("menuFile")
self.menuPlaylist = QtWidgets.QMenu(self.menubar) self.menuPlaylist = QtWidgets.QMenu(self.menubar)
self.menuPlaylist.setObjectName("menuPlaylist") self.menuPlaylist.setObjectName("menuPlaylist")
self.menu_Tracks = QtWidgets.QMenu(self.menubar) self.menu_Music = QtWidgets.QMenu(self.menubar)
self.menu_Tracks.setObjectName("menu_Tracks") self.menu_Music.setObjectName("menu_Music")
self.menuTest = QtWidgets.QMenu(self.menubar)
self.menuTest.setObjectName("menuTest")
MainWindow.setMenuBar(self.menubar) MainWindow.setMenuBar(self.menubar)
self.statusbar = QtWidgets.QStatusBar(MainWindow) self.statusbar = QtWidgets.QStatusBar(MainWindow)
self.statusbar.setEnabled(True) self.statusbar.setEnabled(True)
@ -440,7 +439,14 @@ class Ui_MainWindow(object):
self.actionSelect_unplayed_tracks.setObjectName("actionSelect_unplayed_tracks") self.actionSelect_unplayed_tracks.setObjectName("actionSelect_unplayed_tracks")
self.actionAdd_note = QtWidgets.QAction(MainWindow) self.actionAdd_note = QtWidgets.QAction(MainWindow)
self.actionAdd_note.setObjectName("actionAdd_note") self.actionAdd_note.setObjectName("actionAdd_note")
self.actionEnable_controls = QtWidgets.QAction(MainWindow)
self.actionEnable_controls.setObjectName("actionEnable_controls")
self.actionImport = QtWidgets.QAction(MainWindow)
self.actionImport.setObjectName("actionImport")
self.menuFile.addAction(self.actionImport)
self.menuFile.addSeparator()
self.menuFile.addAction(self.actionE_xit) self.menuFile.addAction(self.actionE_xit)
self.menuFile.addSeparator()
self.menuPlaylist.addAction(self.actionNewPlaylist) self.menuPlaylist.addAction(self.actionNewPlaylist)
self.menuPlaylist.addAction(self.actionOpenPlaylist) self.menuPlaylist.addAction(self.actionOpenPlaylist)
self.menuPlaylist.addAction(self.actionClosePlaylist) self.menuPlaylist.addAction(self.actionClosePlaylist)
@ -448,37 +454,32 @@ class Ui_MainWindow(object):
self.menuPlaylist.addAction(self.actionDeletePlaylist) self.menuPlaylist.addAction(self.actionDeletePlaylist)
self.menuPlaylist.addSeparator() self.menuPlaylist.addSeparator()
self.menuPlaylist.addAction(self.actionSearch_database) self.menuPlaylist.addAction(self.actionSearch_database)
self.menuPlaylist.addAction(self.actionAdd_file)
self.menuPlaylist.addAction(self.actionAdd_note) self.menuPlaylist.addAction(self.actionAdd_note)
self.menuPlaylist.addAction(self.action_Clear_selection) self.menuPlaylist.addAction(self.action_Clear_selection)
self.menuPlaylist.addSeparator() self.menuPlaylist.addSeparator()
self.menuPlaylist.addAction(self.actionSelect_previous_track)
self.menuPlaylist.addAction(self.actionSelect_next_track)
self.menuPlaylist.addAction(self.actionSetNext)
self.menuPlaylist.addSeparator()
self.menuPlaylist.addAction(self.actionSelect_unplayed_tracks) self.menuPlaylist.addAction(self.actionSelect_unplayed_tracks)
self.menuPlaylist.addAction(self.actionSelect_played_tracks) self.menuPlaylist.addAction(self.actionSelect_played_tracks)
self.menuPlaylist.addAction(self.actionMoveSelected) self.menuPlaylist.addAction(self.actionMoveSelected)
self.menuPlaylist.addSeparator() self.menuPlaylist.addSeparator()
self.menuPlaylist.addAction(self.actionExport_playlist) self.menuPlaylist.addAction(self.actionExport_playlist)
self.menuPlaylist.addSeparator() self.menu_Music.addAction(self.actionPlay_next)
self.menuPlaylist.addAction(self.actionSelect_next_track) self.menu_Music.addAction(self.actionSkip_next)
self.menuPlaylist.addAction(self.actionSelect_previous_track) self.menu_Music.addAction(self.actionFade)
self.menu_Tracks.addAction(self.actionPlay_next) self.menu_Music.addAction(self.actionStop)
self.menu_Tracks.addAction(self.actionSkip_next) self.menu_Music.addAction(self.action_Resume_previous)
self.menu_Tracks.addAction(self.actionFade) self.menu_Music.addSeparator()
self.menu_Tracks.addAction(self.actionStop) self.menu_Music.addAction(self.actionEnable_controls)
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.menuFile.menuAction())
self.menubar.addAction(self.menuPlaylist.menuAction()) self.menubar.addAction(self.menuPlaylist.menuAction())
self.menubar.addAction(self.menu_Tracks.menuAction()) self.menubar.addAction(self.menu_Music.menuAction())
self.menubar.addAction(self.menuTest.menuAction())
self.retranslateUi(MainWindow) self.retranslateUi(MainWindow)
self.tabPlaylist.setCurrentIndex(-1) self.tabPlaylist.setCurrentIndex(-1)
self.actionE_xit.triggered.connect(MainWindow.close) self.actionE_xit.triggered.connect(MainWindow.close) # type: ignore
QtCore.QMetaObject.connectSlotsByName(MainWindow) QtCore.QMetaObject.connectSlotsByName(MainWindow)
def retranslateUi(self, MainWindow): def retranslateUi(self, MainWindow):
@ -496,12 +497,12 @@ class Ui_MainWindow(object):
self.btnSetNext.setText(_translate("MainWindow", "Set next")) self.btnSetNext.setText(_translate("MainWindow", "Set next"))
self.btnStop.setText(_translate("MainWindow", "Stop")) self.btnStop.setText(_translate("MainWindow", "Stop"))
self.btnFade.setText(_translate("MainWindow", "Fade")) self.btnFade.setText(_translate("MainWindow", "Fade"))
self.label_2.setText(_translate("MainWindow", "Started at:")) self.label_x.setText(_translate("MainWindow", "Track length:"))
self.label_start_tod.setText(_translate("MainWindow", "00:00:00")) self.label_track_length.setText(_translate("MainWindow", "0: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_7.setText(_translate("MainWindow", "Fade length:"))
self.label_fade_length.setText(_translate("MainWindow", "0:00")) self.label_fade_length.setText(_translate("MainWindow", "0:00"))
self.label_3.setText(_translate("MainWindow", "Silence length:"))
self.label_silence_length.setText(_translate("MainWindow", "0:00"))
self.label.setText(_translate("MainWindow", "Elapsed time")) self.label.setText(_translate("MainWindow", "Elapsed time"))
self.label_elapsed_timer.setText(_translate("MainWindow", "00:00")) self.label_elapsed_timer.setText(_translate("MainWindow", "00:00"))
self.label_4.setText(_translate("MainWindow", "Fade")) self.label_4.setText(_translate("MainWindow", "Fade"))
@ -512,8 +513,7 @@ class Ui_MainWindow(object):
self.label_end_timer.setText(_translate("MainWindow", "00:00")) self.label_end_timer.setText(_translate("MainWindow", "00:00"))
self.menuFile.setTitle(_translate("MainWindow", "Fi&le")) self.menuFile.setTitle(_translate("MainWindow", "Fi&le"))
self.menuPlaylist.setTitle(_translate("MainWindow", "Pla&ylist")) self.menuPlaylist.setTitle(_translate("MainWindow", "Pla&ylist"))
self.menu_Tracks.setTitle(_translate("MainWindow", "&Tracks")) self.menu_Music.setTitle(_translate("MainWindow", "&Music"))
self.menuTest.setTitle(_translate("MainWindow", "TestMo&de"))
self.actionPlay_next.setText(_translate("MainWindow", "&Play next")) self.actionPlay_next.setText(_translate("MainWindow", "&Play next"))
self.actionPlay_next.setShortcut(_translate("MainWindow", "Return")) self.actionPlay_next.setShortcut(_translate("MainWindow", "Return"))
self.actionSkip_next.setText(_translate("MainWindow", "Skip to &next")) self.actionSkip_next.setText(_translate("MainWindow", "Skip to &next"))
@ -550,5 +550,7 @@ class Ui_MainWindow(object):
self.actionSelect_unplayed_tracks.setText(_translate("MainWindow", "Select unplayed tracks")) self.actionSelect_unplayed_tracks.setText(_translate("MainWindow", "Select unplayed tracks"))
self.actionAdd_note.setText(_translate("MainWindow", "Add note...")) self.actionAdd_note.setText(_translate("MainWindow", "Add note..."))
self.actionAdd_note.setShortcut(_translate("MainWindow", "Ctrl+T")) self.actionAdd_note.setShortcut(_translate("MainWindow", "Ctrl+T"))
from musicmuster import ElideLabel self.actionEnable_controls.setText(_translate("MainWindow", "Enable controls"))
self.actionImport.setText(_translate("MainWindow", "Import..."))
self.actionImport.setShortcut(_translate("MainWindow", "Ctrl+Shift+I"))
import icons_rc import icons_rc

17
app/ui_helpers.py Normal file
View File

@ -0,0 +1,17 @@
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QFontMetrics, QPainter
from PyQt5.QtWidgets import QLabel
class ElideLabel(QLabel):
"""
From https://stackoverflow.com/questions/11446478/
pyside-pyqt-truncate-text-in-qlabel-based-on-minimumsize
"""
def paintEvent(self, event):
painter = QPainter(self)
metrics = QFontMetrics(self.font())
elided = metrics.elidedText(self.text(), Qt.ElideRight, self.width())
painter.drawText(self.rect(), self.alignment(), elided)

View File

@ -6,27 +6,31 @@ import shutil
import tempfile import tempfile
from config import Config from config import Config
from helpers import show_warning from helpers import (
fade_point,
get_audio_segment,
get_tags,
leading_silence,
trailing_silence,
)
from log import DEBUG, INFO from log import DEBUG, INFO
from model import Notes, Playdates, PlaylistTracks, Session, Tracks from models import Notes, Playdates, Session, Tracks
from mutagen.flac import FLAC from mutagen.flac import FLAC
from mutagen.mp3 import MP3 from mutagen.mp3 import MP3
from pydub import AudioSegment, effects from pydub import effects
from tinytag import TinyTag
# Globals (I know) # Globals (I know)
messages = [] messages = []
def main(): def main():
"Main loop" """Main loop"""
DEBUG("Starting") DEBUG("Starting")
# Parse command line
p = argparse.ArgumentParser() p = argparse.ArgumentParser()
# Only allow one option to be specified # Only allow one option to be specified
group = p.add_mutually_exclusive_group() group = p.add_mutually_exclusive_group()
group.add_argument('-u', '--update', group.add_argument('-u', '--update',
action="store_true", dest="update", action="store_true", dest="update",
default=False, help="Update database") default=False, help="Update database")
@ -56,7 +60,7 @@ def main():
DEBUG("Finished") DEBUG("Finished")
def create_track_from_file(session, path, interactive=False): def create_track_from_file(session, path, normalise=None, interactive=False):
""" """
Create track in database from passed path, or update database entry Create track in database from passed path, or update database entry
if path already in database. if path already in database.
@ -65,19 +69,20 @@ def create_track_from_file(session, path, interactive=False):
""" """
if interactive: if interactive:
str = f"Importing {path}" msg = f"Importing {path}"
INFO(str) INFO(msg)
INFO("-" * len(str)) INFO("-" * len(msg))
INFO("Get track info...") INFO("Get track info...")
t = get_music_info(path) t = get_tags(path)
title = t['title'] title = t['title']
artist = t['artist'] artist = t['artist']
if interactive: if interactive:
INFO(f" Title: \"{title}\"") INFO(f" Title: \"{title}\"")
INFO(f" Artist: \"{artist}\"") INFO(f" Artist: \"{artist}\"")
# Check for duplicate # Check for duplicate
if interactive:
tracks = Tracks.search_titles(session, title) tracks = Tracks.search_titles(session, title)
if interactive and tracks: if tracks:
print("Found the following possible matches:") print("Found the following possible matches:")
for track in tracks: for track in tracks:
print(f'"{track.title}" by {track.artist}') print(f'"{track.title}" by {track.artist}')
@ -103,7 +108,7 @@ def create_track_from_file(session, path, interactive=False):
track.mtime = os.path.getmtime(path) track.mtime = os.path.getmtime(path)
session.commit() session.commit()
if Config.NORMALISE_ON_IMPORT: if normalise or normalise is None and Config.NORMALISE_ON_IMPORT:
if interactive: if interactive:
INFO("Normalise...") INFO("Normalise...")
# Check type # Check type
@ -119,7 +124,7 @@ def create_track_from_file(session, path, interactive=False):
fd, temp_path = tempfile.mkstemp() fd, temp_path = tempfile.mkstemp()
shutil.copyfile(path, temp_path) shutil.copyfile(path, temp_path)
except Exception as err: except Exception as err:
DEBUG(f"songdb.create_track_from_file({path}): err1: {str(err)}") DEBUG(f"songdb.create_track_from_file({path}): err1: {repr(err)}")
return return
# Overwrite original file with normalised output # Overwrite original file with normalised output
@ -142,7 +147,7 @@ def create_track_from_file(session, path, interactive=False):
dst[tag] = src[tag] dst[tag] = src[tag]
dst.save() dst.save()
except Exception as err: except Exception as err:
DEBUG(f"songdb.create_track_from_file({path}): err2: {str(err)}") DEBUG(f"songdb.create_track_from_file({path}): err2: {repr(err)}")
# Restore original file # Restore original file
shutil.copyfile(path, temp_path) shutil.copyfile(path, temp_path)
finally: finally:
@ -153,7 +158,7 @@ def create_track_from_file(session, path, interactive=False):
def full_update_db(session): def full_update_db(session):
"Rescan all entries in database" """Rescan all entries in database"""
def log(msg): def log(msg):
INFO(f"full_update_db(): {msg}") INFO(f"full_update_db(): {msg}")
@ -223,86 +228,6 @@ def full_update_db(session):
session.commit() 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): def update_db(session):
""" """
Repopulate database Repopulate database
@ -329,7 +254,7 @@ def update_db(session):
for path in list(os_paths - db_paths): for path in list(os_paths - db_paths):
DEBUG(f"songdb.update_db: {path=} not in database") DEBUG(f"songdb.update_db: {path=} not in database")
# is filename in database? # is filename in database?
track = Tracks.get_track_from_filename(session, os.path.basename(path)) track = Tracks.get_by_filename(session, os.path.basename(path))
if not track: if not track:
messages.append(f"Track missing from database: {path}") messages.append(f"Track missing from database: {path}")
else: else:
@ -345,7 +270,7 @@ def update_db(session):
# Remote any tracks from database whose paths don't exist # Remote any tracks from database whose paths don't exist
for path in list(db_paths - os_paths): for path in list(db_paths - os_paths):
# Manage tracks listed in database but where path is invalid # Manage tracks listed in database but where path is invalid
track = Tracks.get_track_from_path(session, path) track = Tracks.get_by_path(session, path)
messages.append(f"Remove from database: {path=} {track=}") messages.append(f"Remove from database: {path=} {track=}")
# Remove references from Playdates # Remove references from Playdates
@ -356,14 +281,15 @@ def update_db(session):
f"File removed: {track.title=}, {track.artist=}, " f"File removed: {track.title=}, {track.artist=}, "
f"{track.path=}" f"{track.path=}"
) )
for pt in PlaylistTracks.get_track_playlists(session, track.id): for playlist in [a.playlist for a in track.playlists]:
# Create note # Create note
Notes.add_note(session, pt.playlist_id, pt.row, note_txt) Notes(session, playlist.id, pt.row, note_txt)
# TODO: this needs to call playlist.add_note() now
# Remove playlist entry # Remove playlist entry
PlaylistTracks.remove_track(session, pt.playlist_id, pt.row) playlist.remove_track(session, pt.row)
# Remove Track entry pointing to invalid path # Remove Track entry pointing to invalid path
Tracks.remove_path(session, path) Tracks.remove_by_path(session, path)
# Output messages (so if running via cron, these will get sent to # Output messages (so if running via cron, these will get sent to
# user) # user)
@ -372,44 +298,5 @@ def update_db(session):
print("\n".join(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(): if __name__ == '__main__' and '__file__' in globals():
main() main()

40
conftest.py Normal file
View File

@ -0,0 +1,40 @@
# https://itnext.io/setting-up-transactional-tests-with-pytest-and-sqlalchemy-b2d726347629
import pytest
import sys
sys.path.append("app")
import models
from sqlalchemy import create_engine
from sqlalchemy.orm import scoped_session, sessionmaker
@pytest.fixture(scope="session")
def connection():
engine = create_engine(
"mysql+mysqldb://musicmuster_testing:musicmuster_testing@"
"localhost/musicmuster_testing"
)
return engine.connect()
@pytest.fixture(scope="session")
def setup_database(connection):
from app.models import Base # noqa E402
Base.metadata.bind = connection
Base.metadata.create_all()
# seed_database()
yield
Base.metadata.drop_all()
@pytest.fixture
def session(setup_database, connection):
transaction = connection.begin()
yield scoped_session(
sessionmaker(autocommit=False, autoflush=False, bind=connection)
)
transaction.rollback()

View File

@ -24,7 +24,7 @@ fileConfig(config.config_file_name)
path = os.path.dirname(os.path.dirname(__file__)) path = os.path.dirname(os.path.dirname(__file__))
sys.path.insert(0, path) sys.path.insert(0, path)
sys.path.insert(0, os.path.join(path, "app")) sys.path.insert(0, os.path.join(path, "app"))
from app.model import Base from app.models import Base
target_metadata = Base.metadata target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py, # other values from the config, defined by the needs of env.py,
# can be acquired: # can be acquired:

View File

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

View File

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

View File

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

844
poetry.lock generated
View File

@ -1,6 +1,6 @@
[[package]] [[package]]
name = "alembic" name = "alembic"
version = "1.7.5" version = "1.7.7"
description = "A database migration tool for SQLAlchemy." description = "A database migration tool for SQLAlchemy."
category = "main" category = "main"
optional = false optional = false
@ -13,6 +13,82 @@ SQLAlchemy = ">=1.3.0"
[package.extras] [package.extras]
tz = ["python-dateutil"] tz = ["python-dateutil"]
[[package]]
name = "appnope"
version = "0.1.2"
description = "Disable App Nap on macOS >= 10.9"
category = "dev"
optional = false
python-versions = "*"
[[package]]
name = "asttokens"
version = "2.0.5"
description = "Annotate AST trees with source code positions"
category = "dev"
optional = false
python-versions = "*"
[package.dependencies]
six = "*"
[package.extras]
test = ["astroid", "pytest"]
[[package]]
name = "atomicwrites"
version = "1.4.0"
description = "Atomic file writes."
category = "dev"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
[[package]]
name = "attrs"
version = "21.4.0"
description = "Classes Without Boilerplate"
category = "dev"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
[package.extras]
dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit", "cloudpickle"]
docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"]
tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "cloudpickle"]
tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "cloudpickle"]
[[package]]
name = "backcall"
version = "0.2.0"
description = "Specifications for callback functions passed in to an API"
category = "dev"
optional = false
python-versions = "*"
[[package]]
name = "colorama"
version = "0.4.4"
description = "Cross-platform colored terminal text."
category = "dev"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
[[package]]
name = "decorator"
version = "5.1.1"
description = "Decorators for Humans"
category = "dev"
optional = false
python-versions = ">=3.5"
[[package]]
name = "executing"
version = "0.8.3"
description = "Get the currently executing AST node of a frame, and other information"
category = "dev"
optional = false
python-versions = "*"
[[package]] [[package]]
name = "greenlet" name = "greenlet"
version = "1.1.2" version = "1.1.2"
@ -24,13 +100,84 @@ python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*"
[package.extras] [package.extras]
docs = ["sphinx"] docs = ["sphinx"]
[[package]]
name = "iniconfig"
version = "1.1.1"
description = "iniconfig: brain-dead simple config-ini parsing"
category = "dev"
optional = false
python-versions = "*"
[[package]]
name = "ipdb"
version = "0.13.9"
description = "IPython-enabled pdb"
category = "dev"
optional = false
python-versions = ">=2.7"
[package.dependencies]
decorator = {version = "*", markers = "python_version > \"3.6\""}
ipython = {version = ">=7.17.0", markers = "python_version > \"3.6\""}
toml = {version = ">=0.10.2", markers = "python_version > \"3.6\""}
[[package]]
name = "ipython"
version = "8.1.1"
description = "IPython: Productive Interactive Computing"
category = "dev"
optional = false
python-versions = ">=3.8"
[package.dependencies]
appnope = {version = "*", markers = "sys_platform == \"darwin\""}
backcall = "*"
colorama = {version = "*", markers = "sys_platform == \"win32\""}
decorator = "*"
jedi = ">=0.16"
matplotlib-inline = "*"
pexpect = {version = ">4.3", markers = "sys_platform != \"win32\""}
pickleshare = "*"
prompt-toolkit = ">=2.0.0,<3.0.0 || >3.0.0,<3.0.1 || >3.0.1,<3.1.0"
pygments = ">=2.4.0"
stack-data = "*"
traitlets = ">=5"
[package.extras]
all = ["black", "Sphinx (>=1.3)", "ipykernel", "nbconvert", "nbformat", "ipywidgets", "notebook", "ipyparallel", "qtconsole", "curio", "matplotlib (!=3.2.0)", "numpy (>=1.19)", "pandas", "pytest", "testpath", "trio", "pytest-asyncio"]
black = ["black"]
doc = ["Sphinx (>=1.3)"]
kernel = ["ipykernel"]
nbconvert = ["nbconvert"]
nbformat = ["nbformat"]
notebook = ["ipywidgets", "notebook"]
parallel = ["ipyparallel"]
qtconsole = ["qtconsole"]
test = ["pytest", "pytest-asyncio", "testpath"]
test_extra = ["curio", "matplotlib (!=3.2.0)", "nbformat", "numpy (>=1.19)", "pandas", "pytest", "testpath", "trio"]
[[package]]
name = "jedi"
version = "0.18.1"
description = "An autocompletion tool for Python that can be used for text editors."
category = "dev"
optional = false
python-versions = ">=3.6"
[package.dependencies]
parso = ">=0.8.0,<0.9.0"
[package.extras]
qa = ["flake8 (==3.8.3)", "mypy (==0.782)"]
testing = ["Django (<3.1)", "colorama", "docopt", "pytest (<7.0.0)"]
[[package]] [[package]]
name = "mako" name = "mako"
version = "1.1.6" version = "1.2.0"
description = "A super-fast templating language that borrows the best ideas from the existing templating languages." description = "A super-fast templating language that borrows the best ideas from the existing templating languages."
category = "main" category = "main"
optional = false optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" python-versions = ">=3.7"
[package.dependencies] [package.dependencies]
MarkupSafe = ">=0.9.2" MarkupSafe = ">=0.9.2"
@ -38,14 +185,26 @@ MarkupSafe = ">=0.9.2"
[package.extras] [package.extras]
babel = ["babel"] babel = ["babel"]
lingua = ["lingua"] lingua = ["lingua"]
testing = ["pytest"]
[[package]] [[package]]
name = "markupsafe" name = "markupsafe"
version = "2.0.1" version = "2.1.0"
description = "Safely add untrusted strings to HTML/XML markup." description = "Safely add untrusted strings to HTML/XML markup."
category = "main" category = "main"
optional = false optional = false
python-versions = ">=3.6" python-versions = ">=3.7"
[[package]]
name = "matplotlib-inline"
version = "0.1.3"
description = "Inline Matplotlib backend for Jupyter"
category = "dev"
optional = false
python-versions = ">=3.5"
[package.dependencies]
traitlets = "*"
[[package]] [[package]]
name = "mutagen" name = "mutagen"
@ -55,6 +214,31 @@ category = "main"
optional = false optional = false
python-versions = ">=3.5, <4" python-versions = ">=3.5, <4"
[[package]]
name = "mypy"
version = "0.931"
description = "Optional static typing for Python"
category = "dev"
optional = false
python-versions = ">=3.6"
[package.dependencies]
mypy-extensions = ">=0.4.3"
tomli = ">=1.1.0"
typing-extensions = ">=3.10"
[package.extras]
dmypy = ["psutil (>=4.0)"]
python2 = ["typed-ast (>=1.4.0,<2)"]
[[package]]
name = "mypy-extensions"
version = "0.4.3"
description = "Experimental type system extensions for programs checked with the mypy typechecker."
category = "dev"
optional = false
python-versions = "*"
[[package]] [[package]]
name = "mysqlclient" name = "mysqlclient"
version = "2.1.0" version = "2.1.0"
@ -63,6 +247,71 @@ category = "main"
optional = false optional = false
python-versions = ">=3.5" python-versions = ">=3.5"
[[package]]
name = "packaging"
version = "21.3"
description = "Core utilities for Python packages"
category = "dev"
optional = false
python-versions = ">=3.6"
[package.dependencies]
pyparsing = ">=2.0.2,<3.0.5 || >3.0.5"
[[package]]
name = "parso"
version = "0.8.3"
description = "A Python Parser"
category = "dev"
optional = false
python-versions = ">=3.6"
[package.extras]
qa = ["flake8 (==3.8.3)", "mypy (==0.782)"]
testing = ["docopt", "pytest (<6.0.0)"]
[[package]]
name = "pexpect"
version = "4.8.0"
description = "Pexpect allows easy control of interactive console applications."
category = "dev"
optional = false
python-versions = "*"
[package.dependencies]
ptyprocess = ">=0.5"
[[package]]
name = "pickleshare"
version = "0.7.5"
description = "Tiny 'shelve'-like database with concurrency support"
category = "dev"
optional = false
python-versions = "*"
[[package]]
name = "pluggy"
version = "1.0.0"
description = "plugin and hook calling mechanisms for python"
category = "dev"
optional = false
python-versions = ">=3.6"
[package.extras]
dev = ["pre-commit", "tox"]
testing = ["pytest", "pytest-benchmark"]
[[package]]
name = "prompt-toolkit"
version = "3.0.28"
description = "Library for building powerful interactive command lines in Python"
category = "dev"
optional = false
python-versions = ">=3.6.2"
[package.dependencies]
wcwidth = "*"
[[package]] [[package]]
name = "psutil" name = "psutil"
version = "5.9.0" version = "5.9.0"
@ -74,6 +323,33 @@ python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
[package.extras] [package.extras]
test = ["ipaddress", "mock", "unittest2", "enum34", "pywin32", "wmi"] test = ["ipaddress", "mock", "unittest2", "enum34", "pywin32", "wmi"]
[[package]]
name = "ptyprocess"
version = "0.7.0"
description = "Run a subprocess in a pseudo terminal"
category = "dev"
optional = false
python-versions = "*"
[[package]]
name = "pure-eval"
version = "0.2.2"
description = "Safely evaluate AST nodes without side effects"
category = "dev"
optional = false
python-versions = "*"
[package.extras]
tests = ["pytest"]
[[package]]
name = "py"
version = "1.11.0"
description = "library with cross-python path, ini-parsing, io, code, log facilities"
category = "dev"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
[[package]] [[package]]
name = "pydub" name = "pydub"
version = "0.25.1" version = "0.25.1"
@ -82,6 +358,25 @@ category = "main"
optional = false optional = false
python-versions = "*" python-versions = "*"
[[package]]
name = "pygments"
version = "2.11.2"
description = "Pygments is a syntax highlighting package written in Python."
category = "dev"
optional = false
python-versions = ">=3.5"
[[package]]
name = "pyparsing"
version = "3.0.7"
description = "Python parsing module"
category = "dev"
optional = false
python-versions = ">=3.6"
[package.extras]
diagrams = ["jinja2", "railroad-diagrams"]
[[package]] [[package]]
name = "pyqt5" name = "pyqt5"
version = "5.15.6" version = "5.15.6"
@ -104,12 +399,23 @@ python-versions = "*"
[[package]] [[package]]
name = "pyqt5-sip" name = "pyqt5-sip"
version = "12.9.0" version = "12.9.1"
description = "The sip module support for PyQt5" description = "The sip module support for PyQt5"
category = "main" category = "main"
optional = false optional = false
python-versions = ">=3.5" python-versions = ">=3.5"
[[package]]
name = "pyqt5-stubs"
version = "5.15.2.0"
description = "PEP561 stub files for the PyQt5 framework"
category = "dev"
optional = false
python-versions = ">= 3.5"
[package.extras]
build = ["docker (==4.2.0)"]
[[package]] [[package]]
name = "pyqtwebengine" name = "pyqtwebengine"
version = "5.15.5" version = "5.15.5"
@ -131,17 +437,61 @@ category = "main"
optional = false optional = false
python-versions = "*" python-versions = "*"
[[package]]
name = "pytest"
version = "7.1.0"
description = "pytest: simple powerful testing with Python"
category = "dev"
optional = false
python-versions = ">=3.7"
[package.dependencies]
atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""}
attrs = ">=19.2.0"
colorama = {version = "*", markers = "sys_platform == \"win32\""}
iniconfig = "*"
packaging = "*"
pluggy = ">=0.12,<2.0"
py = ">=1.8.2"
tomli = ">=1.0.0"
[package.extras]
testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"]
[[package]]
name = "pytest-qt"
version = "4.0.2"
description = "pytest support for PyQt and PySide applications"
category = "dev"
optional = false
python-versions = ">=3.6"
[package.dependencies]
pytest = ">=3.0.0"
[package.extras]
dev = ["pre-commit", "tox"]
doc = ["sphinx", "sphinx-rtd-theme"]
[[package]] [[package]]
name = "python-vlc" name = "python-vlc"
version = "3.0.12118" version = "3.0.16120"
description = "VLC bindings for python." description = "VLC bindings for python."
category = "main" category = "main"
optional = false optional = false
python-versions = "*" python-versions = "*"
[[package]]
name = "six"
version = "1.16.0"
description = "Python 2 and 3 compatibility utilities"
category = "dev"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
[[package]] [[package]]
name = "sqlalchemy" name = "sqlalchemy"
version = "1.4.31" version = "1.4.32"
description = "Database Abstraction Library" description = "Database Abstraction Library"
category = "main" category = "main"
optional = false optional = false
@ -171,26 +521,129 @@ postgresql_psycopg2cffi = ["psycopg2cffi"]
pymysql = ["pymysql (<1)", "pymysql"] pymysql = ["pymysql (<1)", "pymysql"]
sqlcipher = ["sqlcipher3-binary"] sqlcipher = ["sqlcipher3-binary"]
[[package]]
name = "sqlalchemy-stubs"
version = "0.4"
description = "SQLAlchemy stubs and mypy plugin"
category = "dev"
optional = false
python-versions = "*"
[package.dependencies]
mypy = ">=0.790"
typing-extensions = ">=3.7.4"
[[package]]
name = "stack-data"
version = "0.2.0"
description = "Extract data from python stack frames and tracebacks for informative displays"
category = "dev"
optional = false
python-versions = "*"
[package.dependencies]
asttokens = "*"
executing = "*"
pure-eval = "*"
[package.extras]
tests = ["pytest", "typeguard", "pygments", "littleutils", "cython"]
[[package]] [[package]]
name = "tinytag" name = "tinytag"
version = "1.7.0" version = "1.8.1"
description = "Read music meta data and length of MP3, OGG, OPUS, MP4, M4A, FLAC, WMA and Wave files" description = "Read music meta data and length of MP3, OGG, OPUS, MP4, M4A, FLAC, WMA and Wave files"
category = "main" category = "main"
optional = false optional = false
python-versions = ">=2.7" python-versions = ">=2.7"
[package.extras] [package.extras]
tests = ["pytest", "pytest-cov", "coveralls", "flake8"] tests = ["pytest", "pytest-cov", "flake8"]
[[package]]
name = "toml"
version = "0.10.2"
description = "Python Library for Tom's Obvious, Minimal Language"
category = "dev"
optional = false
python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
[[package]]
name = "tomli"
version = "2.0.1"
description = "A lil' TOML parser"
category = "dev"
optional = false
python-versions = ">=3.7"
[[package]]
name = "traitlets"
version = "5.1.1"
description = "Traitlets Python configuration system"
category = "dev"
optional = false
python-versions = ">=3.7"
[package.extras]
test = ["pytest"]
[[package]]
name = "typing-extensions"
version = "4.1.1"
description = "Backported and Experimental Type Hints for Python 3.6+"
category = "dev"
optional = false
python-versions = ">=3.6"
[[package]]
name = "wcwidth"
version = "0.2.5"
description = "Measures the displayed width of unicode strings in a terminal"
category = "dev"
optional = false
python-versions = "*"
[metadata] [metadata]
lock-version = "1.1" lock-version = "1.1"
python-versions = "^3.9" python-versions = "^3.9"
content-hash = "28d15f944ff998687a9427f28c6c2006c7cbeefbe53193676a59a2f971b3c1a9" content-hash = "08353ac7c54559da365ff26807f5179fefe388b62bc52884ee475a4a644e924a"
[metadata.files] [metadata.files]
alembic = [ alembic = [
{file = "alembic-1.7.5-py3-none-any.whl", hash = "sha256:a9dde941534e3d7573d9644e8ea62a2953541e27bc1793e166f60b777ae098b4"}, {file = "alembic-1.7.7-py3-none-any.whl", hash = "sha256:29be0856ec7591c39f4e1cb10f198045d890e6e2274cf8da80cb5e721a09642b"},
{file = "alembic-1.7.5.tar.gz", hash = "sha256:7c328694a2e68f03ee971e63c3bd885846470373a5b532cf2c9f1601c413b153"}, {file = "alembic-1.7.7.tar.gz", hash = "sha256:4961248173ead7ce8a21efb3de378f13b8398e6630fab0eb258dc74a8af24c58"},
]
appnope = [
{file = "appnope-0.1.2-py2.py3-none-any.whl", hash = "sha256:93aa393e9d6c54c5cd570ccadd8edad61ea0c4b9ea7a01409020c9aa019eb442"},
{file = "appnope-0.1.2.tar.gz", hash = "sha256:dd83cd4b5b460958838f6eb3000c660b1f9caf2a5b1de4264e941512f603258a"},
]
asttokens = [
{file = "asttokens-2.0.5-py2.py3-none-any.whl", hash = "sha256:0844691e88552595a6f4a4281a9f7f79b8dd45ca4ccea82e5e05b4bbdb76705c"},
{file = "asttokens-2.0.5.tar.gz", hash = "sha256:9a54c114f02c7a9480d56550932546a3f1fe71d8a02f1bc7ccd0ee3ee35cf4d5"},
]
atomicwrites = [
{file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"},
{file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"},
]
attrs = [
{file = "attrs-21.4.0-py2.py3-none-any.whl", hash = "sha256:2d27e3784d7a565d36ab851fe94887c5eccd6a463168875832a1be79c82828b4"},
{file = "attrs-21.4.0.tar.gz", hash = "sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd"},
]
backcall = [
{file = "backcall-0.2.0-py2.py3-none-any.whl", hash = "sha256:fbbce6a29f263178a1f7915c1940bde0ec2b2a967566fe1c65c1dfb7422bd255"},
{file = "backcall-0.2.0.tar.gz", hash = "sha256:5cbdbf27be5e7cfadb448baf0aa95508f91f2bbc6c6437cd9cd06e2a4c215e1e"},
]
colorama = [
{file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"},
{file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"},
]
decorator = [
{file = "decorator-5.1.1-py3-none-any.whl", hash = "sha256:b8c3f85900b9dc423225913c5aace94729fe1fa9763b38939a95226f02d37186"},
{file = "decorator-5.1.1.tar.gz", hash = "sha256:637996211036b6385ef91435e4fae22989472f9d571faba8927ba8253acbc330"},
]
executing = [
{file = "executing-0.8.3-py2.py3-none-any.whl", hash = "sha256:d1eef132db1b83649a3905ca6dd8897f71ac6f8cac79a7e58a1a09cf137546c9"},
{file = "executing-0.8.3.tar.gz", hash = "sha256:c6554e21c6b060590a6d3be4b82fb78f8f0194d809de5ea7df1c093763311501"},
] ]
greenlet = [ greenlet = [
{file = "greenlet-1.1.2-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:58df5c2a0e293bf665a51f8a100d3e9956febfbf1d9aaf8c0677cf70218910c6"}, {file = "greenlet-1.1.2-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:58df5c2a0e293bf665a51f8a100d3e9956febfbf1d9aaf8c0677cf70218910c6"},
@ -249,85 +702,101 @@ greenlet = [
{file = "greenlet-1.1.2-cp39-cp39-win_amd64.whl", hash = "sha256:013d61294b6cd8fe3242932c1c5e36e5d1db2c8afb58606c5a67efce62c1f5fd"}, {file = "greenlet-1.1.2-cp39-cp39-win_amd64.whl", hash = "sha256:013d61294b6cd8fe3242932c1c5e36e5d1db2c8afb58606c5a67efce62c1f5fd"},
{file = "greenlet-1.1.2.tar.gz", hash = "sha256:e30f5ea4ae2346e62cedde8794a56858a67b878dd79f7df76a0767e356b1744a"}, {file = "greenlet-1.1.2.tar.gz", hash = "sha256:e30f5ea4ae2346e62cedde8794a56858a67b878dd79f7df76a0767e356b1744a"},
] ]
iniconfig = [
{file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"},
{file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"},
]
ipdb = [
{file = "ipdb-0.13.9.tar.gz", hash = "sha256:951bd9a64731c444fd907a5ce268543020086a697f6be08f7cc2c9a752a278c5"},
]
ipython = [
{file = "ipython-8.1.1-py3-none-any.whl", hash = "sha256:6f56bfaeaa3247aa3b9cd3b8cbab3a9c0abf7428392f97b21902d12b2f42a381"},
{file = "ipython-8.1.1.tar.gz", hash = "sha256:8138762243c9b3a3ffcf70b37151a2a35c23d3a29f9743878c33624f4207be3d"},
]
jedi = [
{file = "jedi-0.18.1-py2.py3-none-any.whl", hash = "sha256:637c9635fcf47945ceb91cd7f320234a7be540ded6f3e99a50cb6febdfd1ba8d"},
{file = "jedi-0.18.1.tar.gz", hash = "sha256:74137626a64a99c8eb6ae5832d99b3bdd7d29a3850fe2aa80a4126b2a7d949ab"},
]
mako = [ mako = [
{file = "Mako-1.1.6-py2.py3-none-any.whl", hash = "sha256:afaf8e515d075b22fad7d7b8b30e4a1c90624ff2f3733a06ec125f5a5f043a57"}, {file = "Mako-1.2.0-py3-none-any.whl", hash = "sha256:23aab11fdbbb0f1051b93793a58323ff937e98e34aece1c4219675122e57e4ba"},
{file = "Mako-1.1.6.tar.gz", hash = "sha256:4e9e345a41924a954251b95b4b28e14a301145b544901332e658907a7464b6b2"}, {file = "Mako-1.2.0.tar.gz", hash = "sha256:9a7c7e922b87db3686210cf49d5d767033a41d4010b284e747682c92bddd8b39"},
] ]
markupsafe = [ markupsafe = [
{file = "MarkupSafe-2.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d8446c54dc28c01e5a2dbac5a25f071f6653e6e40f3a8818e8b45d790fe6ef53"}, {file = "MarkupSafe-2.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3028252424c72b2602a323f70fbf50aa80a5d3aa616ea6add4ba21ae9cc9da4c"},
{file = "MarkupSafe-2.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:36bc903cbb393720fad60fc28c10de6acf10dc6cc883f3e24ee4012371399a38"}, {file = "MarkupSafe-2.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:290b02bab3c9e216da57c1d11d2ba73a9f73a614bbdcc027d299a60cdfabb11a"},
{file = "MarkupSafe-2.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2d7d807855b419fc2ed3e631034685db6079889a1f01d5d9dac950f764da3dad"}, {file = "MarkupSafe-2.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e104c0c2b4cd765b4e83909cde7ec61a1e313f8a75775897db321450e928cce"},
{file = "MarkupSafe-2.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:add36cb2dbb8b736611303cd3bfcee00afd96471b09cda130da3581cbdc56a6d"}, {file = "MarkupSafe-2.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24c3be29abb6b34052fd26fc7a8e0a49b1ee9d282e3665e8ad09a0a68faee5b3"},
{file = "MarkupSafe-2.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:168cd0a3642de83558a5153c8bd34f175a9a6e7f6dc6384b9655d2697312a646"}, {file = "MarkupSafe-2.1.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:204730fd5fe2fe3b1e9ccadb2bd18ba8712b111dcabce185af0b3b5285a7c989"},
{file = "MarkupSafe-2.0.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:4dc8f9fb58f7364b63fd9f85013b780ef83c11857ae79f2feda41e270468dd9b"}, {file = "MarkupSafe-2.1.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d3b64c65328cb4cd252c94f83e66e3d7acf8891e60ebf588d7b493a55a1dbf26"},
{file = "MarkupSafe-2.0.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:20dca64a3ef2d6e4d5d615a3fd418ad3bde77a47ec8a23d984a12b5b4c74491a"}, {file = "MarkupSafe-2.1.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:96de1932237abe0a13ba68b63e94113678c379dca45afa040a17b6e1ad7ed076"},
{file = "MarkupSafe-2.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:cdfba22ea2f0029c9261a4bd07e830a8da012291fbe44dc794e488b6c9bb353a"}, {file = "MarkupSafe-2.1.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:75bb36f134883fdbe13d8e63b8675f5f12b80bb6627f7714c7d6c5becf22719f"},
{file = "MarkupSafe-2.0.1-cp310-cp310-win32.whl", hash = "sha256:99df47edb6bda1249d3e80fdabb1dab8c08ef3975f69aed437cb69d0a5de1e28"}, {file = "MarkupSafe-2.1.0-cp310-cp310-win32.whl", hash = "sha256:4056f752015dfa9828dce3140dbadd543b555afb3252507348c493def166d454"},
{file = "MarkupSafe-2.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:e0f138900af21926a02425cf736db95be9f4af72ba1bb21453432a07f6082134"}, {file = "MarkupSafe-2.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:d4e702eea4a2903441f2735799d217f4ac1b55f7d8ad96ab7d4e25417cb0827c"},
{file = "MarkupSafe-2.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f9081981fe268bd86831e5c75f7de206ef275defcb82bc70740ae6dc507aee51"}, {file = "MarkupSafe-2.1.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:f0eddfcabd6936558ec020130f932d479930581171368fd728efcfb6ef0dd357"},
{file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:0955295dd5eec6cb6cc2fe1698f4c6d84af2e92de33fbcac4111913cd100a6ff"}, {file = "MarkupSafe-2.1.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ddea4c352a488b5e1069069f2f501006b1a4362cb906bee9a193ef1245a7a61"},
{file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:0446679737af14f45767963a1a9ef7620189912317d095f2d9ffa183a4d25d2b"}, {file = "MarkupSafe-2.1.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:09c86c9643cceb1d87ca08cdc30160d1b7ab49a8a21564868921959bd16441b8"},
{file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:f826e31d18b516f653fe296d967d700fddad5901ae07c622bb3705955e1faa94"}, {file = "MarkupSafe-2.1.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a0a0abef2ca47b33fb615b491ce31b055ef2430de52c5b3fb19a4042dbc5cadb"},
{file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:fa130dd50c57d53368c9d59395cb5526eda596d3ffe36666cd81a44d56e48872"}, {file = "MarkupSafe-2.1.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:736895a020e31b428b3382a7887bfea96102c529530299f426bf2e636aacec9e"},
{file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:905fec760bd2fa1388bb5b489ee8ee5f7291d692638ea5f67982d968366bef9f"}, {file = "MarkupSafe-2.1.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:679cbb78914ab212c49c67ba2c7396dc599a8479de51b9a87b174700abd9ea49"},
{file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf5d821ffabf0ef3533c39c518f3357b171a1651c1ff6827325e4489b0e46c3c"}, {file = "MarkupSafe-2.1.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:84ad5e29bf8bab3ad70fd707d3c05524862bddc54dc040982b0dbcff36481de7"},
{file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0d4b31cc67ab36e3392bbf3862cfbadac3db12bdd8b02a2731f509ed5b829724"}, {file = "MarkupSafe-2.1.0-cp37-cp37m-win32.whl", hash = "sha256:8da5924cb1f9064589767b0f3fc39d03e3d0fb5aa29e0cb21d43106519bd624a"},
{file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:baa1a4e8f868845af802979fcdbf0bb11f94f1cb7ced4c4b8a351bb60d108145"}, {file = "MarkupSafe-2.1.0-cp37-cp37m-win_amd64.whl", hash = "sha256:454ffc1cbb75227d15667c09f164a0099159da0c1f3d2636aa648f12675491ad"},
{file = "MarkupSafe-2.0.1-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:deb993cacb280823246a026e3b2d81c493c53de6acfd5e6bfe31ab3402bb37dd"}, {file = "MarkupSafe-2.1.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:142119fb14a1ef6d758912b25c4e803c3ff66920635c44078666fe7cc3f8f759"},
{file = "MarkupSafe-2.0.1-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:63f3268ba69ace99cab4e3e3b5840b03340efed0948ab8f78d2fd87ee5442a4f"}, {file = "MarkupSafe-2.1.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b2a5a856019d2833c56a3dcac1b80fe795c95f401818ea963594b345929dffa7"},
{file = "MarkupSafe-2.0.1-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:8d206346619592c6200148b01a2142798c989edcb9c896f9ac9722a99d4e77e6"}, {file = "MarkupSafe-2.1.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d1fb9b2eec3c9714dd936860850300b51dbaa37404209c8d4cb66547884b7ed"},
{file = "MarkupSafe-2.0.1-cp36-cp36m-win32.whl", hash = "sha256:6c4ca60fa24e85fe25b912b01e62cb969d69a23a5d5867682dd3e80b5b02581d"}, {file = "MarkupSafe-2.1.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:62c0285e91414f5c8f621a17b69fc0088394ccdaa961ef469e833dbff64bd5ea"},
{file = "MarkupSafe-2.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b2f4bf27480f5e5e8ce285a8c8fd176c0b03e93dcc6646477d4630e83440c6a9"}, {file = "MarkupSafe-2.1.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fc3150f85e2dbcf99e65238c842d1cfe69d3e7649b19864c1cc043213d9cd730"},
{file = "MarkupSafe-2.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0717a7390a68be14b8c793ba258e075c6f4ca819f15edfc2a3a027c823718567"}, {file = "MarkupSafe-2.1.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:f02cf7221d5cd915d7fa58ab64f7ee6dd0f6cddbb48683debf5d04ae9b1c2cc1"},
{file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:6557b31b5e2c9ddf0de32a691f2312a32f77cd7681d8af66c2692efdbef84c18"}, {file = "MarkupSafe-2.1.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:d5653619b3eb5cbd35bfba3c12d575db2a74d15e0e1c08bf1db788069d410ce8"},
{file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:49e3ceeabbfb9d66c3aef5af3a60cc43b85c33df25ce03d0031a608b0a8b2e3f"}, {file = "MarkupSafe-2.1.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:7d2f5d97fcbd004c03df8d8fe2b973fe2b14e7bfeb2cfa012eaa8759ce9a762f"},
{file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:d7f9850398e85aba693bb640262d3611788b1f29a79f0c93c565694658f4071f"}, {file = "MarkupSafe-2.1.0-cp38-cp38-win32.whl", hash = "sha256:3cace1837bc84e63b3fd2dfce37f08f8c18aeb81ef5cf6bb9b51f625cb4e6cd8"},
{file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:6a7fae0dd14cf60ad5ff42baa2e95727c3d81ded453457771d02b7d2b3f9c0c2"}, {file = "MarkupSafe-2.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:fabbe18087c3d33c5824cb145ffca52eccd053061df1d79d4b66dafa5ad2a5ea"},
{file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:b7f2d075102dc8c794cbde1947378051c4e5180d52d276987b8d28a3bd58c17d"}, {file = "MarkupSafe-2.1.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:023af8c54fe63530545f70dd2a2a7eed18d07a9a77b94e8bf1e2ff7f252db9a3"},
{file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e9936f0b261d4df76ad22f8fee3ae83b60d7c3e871292cd42f40b81b70afae85"}, {file = "MarkupSafe-2.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d66624f04de4af8bbf1c7f21cc06649c1c69a7f84109179add573ce35e46d448"},
{file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:2a7d351cbd8cfeb19ca00de495e224dea7e7d919659c2841bbb7f420ad03e2d6"}, {file = "MarkupSafe-2.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c532d5ab79be0199fa2658e24a02fce8542df196e60665dd322409a03db6a52c"},
{file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:60bf42e36abfaf9aff1f50f52644b336d4f0a3fd6d8a60ca0d054ac9f713a864"}, {file = "MarkupSafe-2.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e67ec74fada3841b8c5f4c4f197bea916025cb9aa3fe5abf7d52b655d042f956"},
{file = "MarkupSafe-2.0.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d6c7ebd4e944c85e2c3421e612a7057a2f48d478d79e61800d81468a8d842207"}, {file = "MarkupSafe-2.1.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30c653fde75a6e5eb814d2a0a89378f83d1d3f502ab710904ee585c38888816c"},
{file = "MarkupSafe-2.0.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:f0567c4dc99f264f49fe27da5f735f414c4e7e7dd850cfd8e69f0862d7c74ea9"}, {file = "MarkupSafe-2.1.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:961eb86e5be7d0973789f30ebcf6caab60b844203f4396ece27310295a6082c7"},
{file = "MarkupSafe-2.0.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:89c687013cb1cd489a0f0ac24febe8c7a666e6e221b783e53ac50ebf68e45d86"}, {file = "MarkupSafe-2.1.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:598b65d74615c021423bd45c2bc5e9b59539c875a9bdb7e5f2a6b92dfcfc268d"},
{file = "MarkupSafe-2.0.1-cp37-cp37m-win32.whl", hash = "sha256:a30e67a65b53ea0a5e62fe23682cfe22712e01f453b95233b25502f7c61cb415"}, {file = "MarkupSafe-2.1.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:599941da468f2cf22bf90a84f6e2a65524e87be2fce844f96f2dd9a6c9d1e635"},
{file = "MarkupSafe-2.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:611d1ad9a4288cf3e3c16014564df047fe08410e628f89805e475368bd304914"}, {file = "MarkupSafe-2.1.0-cp39-cp39-win32.whl", hash = "sha256:e6f7f3f41faffaea6596da86ecc2389672fa949bd035251eab26dc6697451d05"},
{file = "MarkupSafe-2.0.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:5bb28c636d87e840583ee3adeb78172efc47c8b26127267f54a9c0ec251d41a9"}, {file = "MarkupSafe-2.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:b8811d48078d1cf2a6863dafb896e68406c5f513048451cd2ded0473133473c7"},
{file = "MarkupSafe-2.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:be98f628055368795d818ebf93da628541e10b75b41c559fdf36d104c5787066"}, {file = "MarkupSafe-2.1.0.tar.gz", hash = "sha256:80beaf63ddfbc64a0452b841d8036ca0611e049650e20afcb882f5d3c266d65f"},
{file = "MarkupSafe-2.0.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:1d609f577dc6e1aa17d746f8bd3c31aa4d258f4070d61b2aa5c4166c1539de35"}, ]
{file = "MarkupSafe-2.0.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7d91275b0245b1da4d4cfa07e0faedd5b0812efc15b702576d103293e252af1b"}, matplotlib-inline = [
{file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:01a9b8ea66f1658938f65b93a85ebe8bc016e6769611be228d797c9d998dd298"}, {file = "matplotlib-inline-0.1.3.tar.gz", hash = "sha256:a04bfba22e0d1395479f866853ec1ee28eea1485c1d69a6faf00dc3e24ff34ee"},
{file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:47ab1e7b91c098ab893b828deafa1203de86d0bc6ab587b160f78fe6c4011f75"}, {file = "matplotlib_inline-0.1.3-py3-none-any.whl", hash = "sha256:aed605ba3b72462d64d475a21a9296f400a19c4f74a31b59103d2a99ffd5aa5c"},
{file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:97383d78eb34da7e1fa37dd273c20ad4320929af65d156e35a5e2d89566d9dfb"},
{file = "MarkupSafe-2.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6fcf051089389abe060c9cd7caa212c707e58153afa2c649f00346ce6d260f1b"},
{file = "MarkupSafe-2.0.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:5855f8438a7d1d458206a2466bf82b0f104a3724bf96a1c781ab731e4201731a"},
{file = "MarkupSafe-2.0.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:3dd007d54ee88b46be476e293f48c85048603f5f516008bee124ddd891398ed6"},
{file = "MarkupSafe-2.0.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:aca6377c0cb8a8253e493c6b451565ac77e98c2951c45f913e0b52facdcff83f"},
{file = "MarkupSafe-2.0.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:04635854b943835a6ea959e948d19dcd311762c5c0c6e1f0e16ee57022669194"},
{file = "MarkupSafe-2.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6300b8454aa6930a24b9618fbb54b5a68135092bc666f7b06901f897fa5c2fee"},
{file = "MarkupSafe-2.0.1-cp38-cp38-win32.whl", hash = "sha256:023cb26ec21ece8dc3907c0e8320058b2e0cb3c55cf9564da612bc325bed5e64"},
{file = "MarkupSafe-2.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:984d76483eb32f1bcb536dc27e4ad56bba4baa70be32fa87152832cdd9db0833"},
{file = "MarkupSafe-2.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:2ef54abee730b502252bcdf31b10dacb0a416229b72c18b19e24a4509f273d26"},
{file = "MarkupSafe-2.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3c112550557578c26af18a1ccc9e090bfe03832ae994343cfdacd287db6a6ae7"},
{file = "MarkupSafe-2.0.1-cp39-cp39-manylinux1_i686.whl", hash = "sha256:53edb4da6925ad13c07b6d26c2a852bd81e364f95301c66e930ab2aef5b5ddd8"},
{file = "MarkupSafe-2.0.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:f5653a225f31e113b152e56f154ccbe59eeb1c7487b39b9d9f9cdb58e6c79dc5"},
{file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:4efca8f86c54b22348a5467704e3fec767b2db12fc39c6d963168ab1d3fc9135"},
{file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:ab3ef638ace319fa26553db0624c4699e31a28bb2a835c5faca8f8acf6a5a902"},
{file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:f8ba0e8349a38d3001fae7eadded3f6606f0da5d748ee53cc1dab1d6527b9509"},
{file = "MarkupSafe-2.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c47adbc92fc1bb2b3274c4b3a43ae0e4573d9fbff4f54cd484555edbf030baf1"},
{file = "MarkupSafe-2.0.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:37205cac2a79194e3750b0af2a5720d95f786a55ce7df90c3af697bfa100eaac"},
{file = "MarkupSafe-2.0.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:1f2ade76b9903f39aa442b4aadd2177decb66525062db244b35d71d0ee8599b6"},
{file = "MarkupSafe-2.0.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:4296f2b1ce8c86a6aea78613c34bb1a672ea0e3de9c6ba08a960efe0b0a09047"},
{file = "MarkupSafe-2.0.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9f02365d4e99430a12647f09b6cc8bab61a6564363f313126f775eb4f6ef798e"},
{file = "MarkupSafe-2.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5b6d930f030f8ed98e3e6c98ffa0652bdb82601e7a016ec2ab5d7ff23baa78d1"},
{file = "MarkupSafe-2.0.1-cp39-cp39-win32.whl", hash = "sha256:10f82115e21dc0dfec9ab5c0223652f7197feb168c940f3ef61563fc2d6beb74"},
{file = "MarkupSafe-2.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:693ce3f9e70a6cf7d2fb9e6c9d8b204b6b39897a2c4a1aa65728d5ac97dcc1d8"},
{file = "MarkupSafe-2.0.1.tar.gz", hash = "sha256:594c67807fb16238b30c44bdf74f36c02cdf22d1c8cda91ef8a0ed8dabf5620a"},
] ]
mutagen = [ mutagen = [
{file = "mutagen-1.45.1-py3-none-any.whl", hash = "sha256:9c9f243fcec7f410f138cb12c21c84c64fde4195481a30c9bfb05b5f003adfed"}, {file = "mutagen-1.45.1-py3-none-any.whl", hash = "sha256:9c9f243fcec7f410f138cb12c21c84c64fde4195481a30c9bfb05b5f003adfed"},
{file = "mutagen-1.45.1.tar.gz", hash = "sha256:6397602efb3c2d7baebd2166ed85731ae1c1d475abca22090b7141ff5034b3e1"}, {file = "mutagen-1.45.1.tar.gz", hash = "sha256:6397602efb3c2d7baebd2166ed85731ae1c1d475abca22090b7141ff5034b3e1"},
] ]
mypy = [
{file = "mypy-0.931-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3c5b42d0815e15518b1f0990cff7a705805961613e701db60387e6fb663fe78a"},
{file = "mypy-0.931-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c89702cac5b302f0c5d33b172d2b55b5df2bede3344a2fbed99ff96bddb2cf00"},
{file = "mypy-0.931-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:300717a07ad09525401a508ef5d105e6b56646f7942eb92715a1c8d610149714"},
{file = "mypy-0.931-cp310-cp310-win_amd64.whl", hash = "sha256:7b3f6f557ba4afc7f2ce6d3215d5db279bcf120b3cfd0add20a5d4f4abdae5bc"},
{file = "mypy-0.931-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:1bf752559797c897cdd2c65f7b60c2b6969ffe458417b8d947b8340cc9cec08d"},
{file = "mypy-0.931-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:4365c60266b95a3f216a3047f1d8e3f895da6c7402e9e1ddfab96393122cc58d"},
{file = "mypy-0.931-cp36-cp36m-win_amd64.whl", hash = "sha256:1b65714dc296a7991000b6ee59a35b3f550e0073411ac9d3202f6516621ba66c"},
{file = "mypy-0.931-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:e839191b8da5b4e5d805f940537efcaa13ea5dd98418f06dc585d2891d228cf0"},
{file = "mypy-0.931-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:50c7346a46dc76a4ed88f3277d4959de8a2bd0a0fa47fa87a4cde36fe247ac05"},
{file = "mypy-0.931-cp37-cp37m-win_amd64.whl", hash = "sha256:d8f1ff62f7a879c9fe5917b3f9eb93a79b78aad47b533911b853a757223f72e7"},
{file = "mypy-0.931-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f9fe20d0872b26c4bba1c1be02c5340de1019530302cf2dcc85c7f9fc3252ae0"},
{file = "mypy-0.931-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:1b06268df7eb53a8feea99cbfff77a6e2b205e70bf31743e786678ef87ee8069"},
{file = "mypy-0.931-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:8c11003aaeaf7cc2d0f1bc101c1cc9454ec4cc9cb825aef3cafff8a5fdf4c799"},
{file = "mypy-0.931-cp38-cp38-win_amd64.whl", hash = "sha256:d9d2b84b2007cea426e327d2483238f040c49405a6bf4074f605f0156c91a47a"},
{file = "mypy-0.931-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ff3bf387c14c805ab1388185dd22d6b210824e164d4bb324b195ff34e322d166"},
{file = "mypy-0.931-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5b56154f8c09427bae082b32275a21f500b24d93c88d69a5e82f3978018a0266"},
{file = "mypy-0.931-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:8ca7f8c4b1584d63c9a0f827c37ba7a47226c19a23a753d52e5b5eddb201afcd"},
{file = "mypy-0.931-cp39-cp39-win_amd64.whl", hash = "sha256:74f7eccbfd436abe9c352ad9fb65872cc0f1f0a868e9d9c44db0893440f0c697"},
{file = "mypy-0.931-py3-none-any.whl", hash = "sha256:1171f2e0859cfff2d366da2c7092b06130f232c636a3f7301e3feb8b41f6377d"},
{file = "mypy-0.931.tar.gz", hash = "sha256:0038b21890867793581e4cb0d810829f5fd4441aa75796b53033af3aa30430ce"},
]
mypy-extensions = [
{file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"},
{file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"},
]
mysqlclient = [ mysqlclient = [
{file = "mysqlclient-2.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:02c8826e6add9b20f4cb12dcf016485f7b1d6e30356a1204d05431867a1b3947"}, {file = "mysqlclient-2.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:02c8826e6add9b20f4cb12dcf016485f7b1d6e30356a1204d05431867a1b3947"},
{file = "mysqlclient-2.1.0-cp37-cp37m-win_amd64.whl", hash = "sha256:b62d23c11c516cedb887377c8807628c1c65d57593b57853186a6ee18b0c6a5b"}, {file = "mysqlclient-2.1.0-cp37-cp37m-win_amd64.whl", hash = "sha256:b62d23c11c516cedb887377c8807628c1c65d57593b57853186a6ee18b0c6a5b"},
@ -335,6 +804,30 @@ mysqlclient = [
{file = "mysqlclient-2.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:e6279263d5a9feca3e0edbc2b2a52c057375bf301d47da2089c075ff76331d14"}, {file = "mysqlclient-2.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:e6279263d5a9feca3e0edbc2b2a52c057375bf301d47da2089c075ff76331d14"},
{file = "mysqlclient-2.1.0.tar.gz", hash = "sha256:973235686f1b720536d417bf0a0d39b4ab3d5086b2b6ad5e6752393428c02b12"}, {file = "mysqlclient-2.1.0.tar.gz", hash = "sha256:973235686f1b720536d417bf0a0d39b4ab3d5086b2b6ad5e6752393428c02b12"},
] ]
packaging = [
{file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"},
{file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"},
]
parso = [
{file = "parso-0.8.3-py2.py3-none-any.whl", hash = "sha256:c001d4636cd3aecdaf33cbb40aebb59b094be2a74c556778ef5576c175e19e75"},
{file = "parso-0.8.3.tar.gz", hash = "sha256:8c07be290bb59f03588915921e29e8a50002acaf2cdc5fa0e0114f91709fafa0"},
]
pexpect = [
{file = "pexpect-4.8.0-py2.py3-none-any.whl", hash = "sha256:0b48a55dcb3c05f3329815901ea4fc1537514d6ba867a152b581d69ae3710937"},
{file = "pexpect-4.8.0.tar.gz", hash = "sha256:fc65a43959d153d0114afe13997d439c22823a27cefceb5ff35c2178c6784c0c"},
]
pickleshare = [
{file = "pickleshare-0.7.5-py2.py3-none-any.whl", hash = "sha256:9649af414d74d4df115d5d718f82acb59c9d418196b7b4290ed47a12ce62df56"},
{file = "pickleshare-0.7.5.tar.gz", hash = "sha256:87683d47965c1da65cdacaf31c8441d12b8044cdec9aca500cd78fc2c683afca"},
]
pluggy = [
{file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"},
{file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"},
]
prompt-toolkit = [
{file = "prompt_toolkit-3.0.28-py3-none-any.whl", hash = "sha256:30129d870dcb0b3b6a53efdc9d0a83ea96162ffd28ffe077e94215b233dc670c"},
{file = "prompt_toolkit-3.0.28.tar.gz", hash = "sha256:9f1cd16b1e86c2968f2519d7fb31dd9d669916f515612c269d14e9ed52b51650"},
]
psutil = [ psutil = [
{file = "psutil-5.9.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:55ce319452e3d139e25d6c3f85a1acf12d1607ddedea5e35fb47a552c051161b"}, {file = "psutil-5.9.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:55ce319452e3d139e25d6c3f85a1acf12d1607ddedea5e35fb47a552c051161b"},
{file = "psutil-5.9.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:7336292a13a80eb93c21f36bde4328aa748a04b68c13d01dfddd67fc13fd0618"}, {file = "psutil-5.9.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:7336292a13a80eb93c21f36bde4328aa748a04b68c13d01dfddd67fc13fd0618"},
@ -369,10 +862,30 @@ psutil = [
{file = "psutil-5.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:7d190ee2eaef7831163f254dc58f6d2e2a22e27382b936aab51c835fc080c3d3"}, {file = "psutil-5.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:7d190ee2eaef7831163f254dc58f6d2e2a22e27382b936aab51c835fc080c3d3"},
{file = "psutil-5.9.0.tar.gz", hash = "sha256:869842dbd66bb80c3217158e629d6fceaecc3a3166d3d1faee515b05dd26ca25"}, {file = "psutil-5.9.0.tar.gz", hash = "sha256:869842dbd66bb80c3217158e629d6fceaecc3a3166d3d1faee515b05dd26ca25"},
] ]
ptyprocess = [
{file = "ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35"},
{file = "ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220"},
]
pure-eval = [
{file = "pure_eval-0.2.2-py3-none-any.whl", hash = "sha256:01eaab343580944bc56080ebe0a674b39ec44a945e6d09ba7db3cb8cec289350"},
{file = "pure_eval-0.2.2.tar.gz", hash = "sha256:2b45320af6dfaa1750f543d714b6d1c520a1688dec6fd24d339063ce0aaa9ac3"},
]
py = [
{file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"},
{file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"},
]
pydub = [ pydub = [
{file = "pydub-0.25.1-py2.py3-none-any.whl", hash = "sha256:65617e33033874b59d87db603aa1ed450633288aefead953b30bded59cb599a6"}, {file = "pydub-0.25.1-py2.py3-none-any.whl", hash = "sha256:65617e33033874b59d87db603aa1ed450633288aefead953b30bded59cb599a6"},
{file = "pydub-0.25.1.tar.gz", hash = "sha256:980a33ce9949cab2a569606b65674d748ecbca4f0796887fd6f46173a7b0d30f"}, {file = "pydub-0.25.1.tar.gz", hash = "sha256:980a33ce9949cab2a569606b65674d748ecbca4f0796887fd6f46173a7b0d30f"},
] ]
pygments = [
{file = "Pygments-2.11.2-py3-none-any.whl", hash = "sha256:44238f1b60a76d78fc8ca0528ee429702aae011c265fe6a8dd8b63049ae41c65"},
{file = "Pygments-2.11.2.tar.gz", hash = "sha256:4e426f72023d88d03b2fa258de560726ce890ff3b630f88c21cbb8b2503b8c6a"},
]
pyparsing = [
{file = "pyparsing-3.0.7-py3-none-any.whl", hash = "sha256:a6c06a88f252e6c322f65faf8f418b16213b51bdfaece0524c1c1bc30c63c484"},
{file = "pyparsing-3.0.7.tar.gz", hash = "sha256:18ee9022775d270c55187733956460083db60b37d0d0fb357445f3094eed3eea"},
]
pyqt5 = [ pyqt5 = [
{file = "PyQt5-5.15.6-cp36-abi3-macosx_10_13_x86_64.whl", hash = "sha256:33ced1c876f6a26e7899615a5a4efef2167c263488837c7beed023a64cebd051"}, {file = "PyQt5-5.15.6-cp36-abi3-macosx_10_13_x86_64.whl", hash = "sha256:33ced1c876f6a26e7899615a5a4efef2167c263488837c7beed023a64cebd051"},
{file = "PyQt5-5.15.6-cp36-abi3-manylinux1_x86_64.whl", hash = "sha256:9d6efad0377aa78bf081a20ac752ce86096ded18f04c592d98f5b92dc879ad0a"}, {file = "PyQt5-5.15.6-cp36-abi3-manylinux1_x86_64.whl", hash = "sha256:9d6efad0377aa78bf081a20ac752ce86096ded18f04c592d98f5b92dc879ad0a"},
@ -387,27 +900,31 @@ pyqt5-qt5 = [
{file = "PyQt5_Qt5-5.15.2-py3-none-win_amd64.whl", hash = "sha256:750b78e4dba6bdf1607febedc08738e318ea09e9b10aea9ff0d73073f11f6962"}, {file = "PyQt5_Qt5-5.15.2-py3-none-win_amd64.whl", hash = "sha256:750b78e4dba6bdf1607febedc08738e318ea09e9b10aea9ff0d73073f11f6962"},
] ]
pyqt5-sip = [ pyqt5-sip = [
{file = "PyQt5_sip-12.9.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:6d5bca2fc222d58e8093ee8a81a6e3437067bb22bc3f86d06ec8be721e15e90a"}, {file = "PyQt5_sip-12.9.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:6b2e553e21b7ff124007a6b9168f8bb8c171fdf230d31ca0588df180f10bacbe"},
{file = "PyQt5_sip-12.9.0-cp310-cp310-manylinux1_x86_64.whl", hash = "sha256:d59af63120d1475b2bf94fe8062610720a9be1e8940ea146c7f42bb449d49067"}, {file = "PyQt5_sip-12.9.1-cp310-cp310-manylinux1_x86_64.whl", hash = "sha256:5740a1770d6b92a5dca8bb0bda4620baf0d7a726beb864f69c667ddac91d6f64"},
{file = "PyQt5_sip-12.9.0-cp310-cp310-win32.whl", hash = "sha256:0fc9aefacf502696710b36cdc9fa2a61487f55ee883dbcf2c2a6477e261546f7"}, {file = "PyQt5_sip-12.9.1-cp310-cp310-win32.whl", hash = "sha256:9699286fcdf4f75a4b91c7e4832c0f926af18d648c62a4ed72dd294c1a93705a"},
{file = "PyQt5_sip-12.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:485972daff2fb0311013f471998f8ec8262ea381bded244f9d14edaad5f54271"}, {file = "PyQt5_sip-12.9.1-cp310-cp310-win_amd64.whl", hash = "sha256:e2792af660da7479799f53028da88190ae8b4a0ad5acc2acbfd6c7bbfe110d58"},
{file = "PyQt5_sip-12.9.0-cp36-cp36m-macosx_10_6_intel.whl", hash = "sha256:d85002238b5180bce4b245c13d6face848faa1a7a9e5c6e292025004f2fd619a"}, {file = "PyQt5_sip-12.9.1-cp36-cp36m-macosx_10_6_intel.whl", hash = "sha256:7ee08ad0ebf85b935f5d8d38306f8665fff9a6026c14fc0a7d780649e889c096"},
{file = "PyQt5_sip-12.9.0-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:83c3220b1ca36eb8623ba2eb3766637b19eb0ce9f42336ad8253656d32750c0a"}, {file = "PyQt5_sip-12.9.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:2d21420b0739df2607864e2c80ca01994bc40cb116da6ad024ea8d9f407b0356"},
{file = "PyQt5_sip-12.9.0-cp36-cp36m-win32.whl", hash = "sha256:d8b2bdff7bbf45bc975c113a03b14fd669dc0c73e1327f02706666a7dd51a197"}, {file = "PyQt5_sip-12.9.1-cp36-cp36m-win32.whl", hash = "sha256:ffd25051962c593d1c3c30188b9fbd8589ba17acd23a0202dc987bd3552fa611"},
{file = "PyQt5_sip-12.9.0-cp36-cp36m-win_amd64.whl", hash = "sha256:69a3ad4259172e2b1aa9060de211efac39ddd734a517b1924d9c6c0cc4f55f96"}, {file = "PyQt5_sip-12.9.1-cp36-cp36m-win_amd64.whl", hash = "sha256:78ef8f1f41819661aa8e3117d6c1cd76fa14aef265e5bfd515dbfc64d412416b"},
{file = "PyQt5_sip-12.9.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:42274a501ab4806d2c31659170db14c282b8313d2255458064666d9e70d96206"}, {file = "PyQt5_sip-12.9.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:5e641182bfee0501267c55e687832e4efe05becdae9e555d3695d706009b6598"},
{file = "PyQt5_sip-12.9.0-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:6a8701892a01a5a2a4720872361197cc80fdd5f49c8482d488ddf38c9c84f055"}, {file = "PyQt5_sip-12.9.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:c9a977d2835a5fbf250b00d61267dc228bdec9e20c7420d4e8d54d6f20410f87"},
{file = "PyQt5_sip-12.9.0-cp37-cp37m-win32.whl", hash = "sha256:ac57d796c78117eb39edd1d1d1aea90354651efac9d3590aac67fa4983f99f1f"}, {file = "PyQt5_sip-12.9.1-cp37-cp37m-win32.whl", hash = "sha256:cec6ebf0b1163b18f09bc523160c467a9528b6dca129753827ac0bc432b332ae"},
{file = "PyQt5_sip-12.9.0-cp37-cp37m-win_amd64.whl", hash = "sha256:4347bd81d30c8e3181e553b3734f91658cfbdd8f1a19f254777f906870974e6d"}, {file = "PyQt5_sip-12.9.1-cp37-cp37m-win_amd64.whl", hash = "sha256:82c1b3080db7634fa318fddbb3cfaa30e63a67bca1001a76958c31f30b774a9d"},
{file = "PyQt5_sip-12.9.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:c446971c360a0a1030282a69375a08c78e8a61d568bfd6dab3dcc5cf8817f644"}, {file = "PyQt5_sip-12.9.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cfaad4a773c18b963092589b1a98153d36624601de8597a4dc287e5a295d5625"},
{file = "PyQt5_sip-12.9.0-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:fc43f2d7c438517ee33e929e8ae77132749c15909afab6aeece5fcf4147ffdb5"}, {file = "PyQt5_sip-12.9.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:ce7a8b3af9db378c46b345d9809d481a74c4bfcd3129486c054fbdbc6a3503f9"},
{file = "PyQt5_sip-12.9.0-cp38-cp38-win32.whl", hash = "sha256:055581c6fed44ba4302b70eeb82e979ff70400037358908f251cd85cbb3dbd93"}, {file = "PyQt5_sip-12.9.1-cp38-cp38-win32.whl", hash = "sha256:8fe5b3e4bbb8b472d05631cad21028d073f9f8eda770041449514cb3824a867f"},
{file = "PyQt5_sip-12.9.0-cp38-cp38-win_amd64.whl", hash = "sha256:c5216403d4d8d857ec4a61f631d3945e44fa248aa2415e9ee9369ab7c8a4d0c7"}, {file = "PyQt5_sip-12.9.1-cp38-cp38-win_amd64.whl", hash = "sha256:5d59c4a5e856a35c41b47f5a23e1635b38cd1672f4f0122a68ebcb6889523ff2"},
{file = "PyQt5_sip-12.9.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a25b9843c7da6a1608f310879c38e6434331aab1dc2fe6cb65c14f1ecf33780e"}, {file = "PyQt5_sip-12.9.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b56aedf7b0a496e4a8bd6087566888cea448aa01c76126cdb8b140e3ff3f5d93"},
{file = "PyQt5_sip-12.9.0-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:dd05c768c2b55ffe56a9d49ce6cc77cdf3d53dbfad935258a9e347cbfd9a5850"}, {file = "PyQt5_sip-12.9.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:53e23dcc0fc3857204abd47660e383b930941bd1aeaf3c78ed59c5c12dd48010"},
{file = "PyQt5_sip-12.9.0-cp39-cp39-win32.whl", hash = "sha256:4f8e05fe01d54275877c59018d8e82dcdd0bc5696053a8b830eecea3ce806121"}, {file = "PyQt5_sip-12.9.1-cp39-cp39-win32.whl", hash = "sha256:ee188eac5fd94dfe8d9e04a9e7fbda65c3535d5709278d8b7367ebd54f00e27f"},
{file = "PyQt5_sip-12.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:b09f4cd36a4831229fb77c424d89635fa937d97765ec90685e2f257e56a2685a"}, {file = "PyQt5_sip-12.9.1-cp39-cp39-win_amd64.whl", hash = "sha256:989d51c41456cc496cb96f0b341464932b957040d26561f0bb4cf5a0914d6b36"},
{file = "PyQt5_sip-12.9.0.tar.gz", hash = "sha256:d3e4489d7c2b0ece9d203ae66e573939f7f60d4d29e089c9f11daa17cfeaae32"}, {file = "PyQt5_sip-12.9.1.tar.gz", hash = "sha256:2f24f299b44c511c23796aafbbb581bfdebf78d0905657b7cee2141b4982030e"},
]
pyqt5-stubs = [
{file = "PyQt5-stubs-5.15.2.0.tar.gz", hash = "sha256:dc0dea66f02fe297fb0cddd5767fbf58275b54ecb67f69ebeb994e1553bfb9b2"},
{file = "PyQt5_stubs-5.15.2.0-py3-none-any.whl", hash = "sha256:4b750d04ffca1bb188615d1a4e7d655a1d81d30df6ee3488f0adfe09b78e3d36"},
] ]
pyqtwebengine = [ pyqtwebengine = [
{file = "PyQtWebEngine-5.15.5-cp36-abi3-macosx_10_13_x86_64.whl", hash = "sha256:5c77f71d88d871bc7400c68ef6433fadc5bd57b86d1a9c4d8094cea42f3607f1"}, {file = "PyQtWebEngine-5.15.5-cp36-abi3-macosx_10_13_x86_64.whl", hash = "sha256:5c77f71d88d871bc7400c68ef6433fadc5bd57b86d1a9c4d8094cea42f3607f1"},
@ -422,48 +939,87 @@ pyqtwebengine-qt5 = [
{file = "PyQtWebEngine_Qt5-5.15.2-py3-none-win32.whl", hash = "sha256:9e80b408d8de09d4e708d5d84c3ceaf3603292ff8f5e566ae44bb0320fa59c33"}, {file = "PyQtWebEngine_Qt5-5.15.2-py3-none-win32.whl", hash = "sha256:9e80b408d8de09d4e708d5d84c3ceaf3603292ff8f5e566ae44bb0320fa59c33"},
{file = "PyQtWebEngine_Qt5-5.15.2-py3-none-win_amd64.whl", hash = "sha256:24231f19e1595018779977de6722b5c69f3d03f34a5f7574ff21cd1e764ef76d"}, {file = "PyQtWebEngine_Qt5-5.15.2-py3-none-win_amd64.whl", hash = "sha256:24231f19e1595018779977de6722b5c69f3d03f34a5f7574ff21cd1e764ef76d"},
] ]
pytest = [
{file = "pytest-7.1.0-py3-none-any.whl", hash = "sha256:b555252a95bbb2a37a97b5ac2eb050c436f7989993565f5e0c9128fcaacadd0e"},
{file = "pytest-7.1.0.tar.gz", hash = "sha256:f1089d218cfcc63a212c42896f1b7fbf096874d045e1988186861a1a87d27b47"},
]
pytest-qt = [
{file = "pytest-qt-4.0.2.tar.gz", hash = "sha256:dfc5240dec7eb43b76bcb5f9a87eecae6ef83592af49f3af5f1d5d093acaa93e"},
{file = "pytest_qt-4.0.2-py2.py3-none-any.whl", hash = "sha256:e03847ac02a890ccaac0fde1748855b9dce425aceba62005c6cfced6cf7d5456"},
]
python-vlc = [ python-vlc = [
{file = "python-vlc-3.0.12118.tar.gz", hash = "sha256:566f2f7c303f6800851cacc016df1c6eeec094ad63e0a49d87db9d698094f1fb"}, {file = "python-vlc-3.0.16120.tar.gz", hash = "sha256:92f98fee088f72bd6d063b3b3312d0bd29b37e7ad65ddeb3a7303320300c2807"},
{file = "python_vlc-3.0.12118-py3-none-any.whl", hash = "sha256:f88be06c6f819a4db2de1c586b193b5df1410ff10fca33b8c6f4e56037c46f7b"}, {file = "python_vlc-3.0.16120-py3-none-any.whl", hash = "sha256:c409afb38fe9f788a663b4302ca583f31289ef0860ab2b1668da96bbe8f14bfc"},
]
six = [
{file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"},
{file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
] ]
sqlalchemy = [ sqlalchemy = [
{file = "SQLAlchemy-1.4.31-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:c3abc34fed19fdeaead0ced8cf56dd121f08198008c033596aa6aae7cc58f59f"}, {file = "SQLAlchemy-1.4.32-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:4b2bcab3a914715d332ca783e9bda13bc570d8b9ef087563210ba63082c18c16"},
{file = "SQLAlchemy-1.4.31-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:8d0949b11681380b4a50ac3cd075e4816afe9fa4a8c8ae006c1ca26f0fa40ad8"}, {file = "SQLAlchemy-1.4.32-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:159c2f69dd6efd28e894f261ffca1100690f28210f34cfcd70b895e0ea7a64f3"},
{file = "SQLAlchemy-1.4.31-cp27-cp27m-win32.whl", hash = "sha256:f3b7ec97e68b68cb1f9ddb82eda17b418f19a034fa8380a0ac04e8fe01532875"}, {file = "SQLAlchemy-1.4.32-cp27-cp27m-win_amd64.whl", hash = "sha256:d7e483f4791fbda60e23926b098702340504f7684ce7e1fd2c1bf02029288423"},
{file = "SQLAlchemy-1.4.31-cp27-cp27m-win_amd64.whl", hash = "sha256:81f2dd355b57770fdf292b54f3e0a9823ec27a543f947fa2eb4ec0df44f35f0d"}, {file = "SQLAlchemy-1.4.32-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:4aa96e957141006181ca58e792e900ee511085b8dae06c2d08c00f108280fb8a"},
{file = "SQLAlchemy-1.4.31-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:4ad31cec8b49fd718470328ad9711f4dc703507d434fd45461096da0a7135ee0"}, {file = "SQLAlchemy-1.4.32-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:576684771456d02e24078047c2567025f2011977aa342063468577d94e194b00"},
{file = "SQLAlchemy-1.4.31-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:05fa14f279d43df68964ad066f653193187909950aa0163320b728edfc400167"}, {file = "SQLAlchemy-1.4.32-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff677fa4522dafb5a5e2c0cf909790d5d367326321aeabc0dffc9047cb235bd"},
{file = "SQLAlchemy-1.4.31-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dccff41478050e823271642837b904d5f9bda3f5cf7d371ce163f00a694118d6"}, {file = "SQLAlchemy-1.4.32-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:8679f9aba5ac22e7bce54ccd8a77641d3aea3e2d96e73e4356c887ebf8ff1082"},
{file = "SQLAlchemy-1.4.31-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:57205844f246bab9b666a32f59b046add8995c665d9ecb2b7b837b087df90639"}, {file = "SQLAlchemy-1.4.32-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c7046f7aa2db445daccc8424f50b47a66c4039c9f058246b43796aa818f8b751"},
{file = "SQLAlchemy-1.4.31-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ea8210090a816d48a4291a47462bac750e3bc5c2442e6d64f7b8137a7c3f9ac5"}, {file = "SQLAlchemy-1.4.32-cp310-cp310-win32.whl", hash = "sha256:bedd89c34ab62565d44745212814e4b57ef1c24ad4af9b29c504ce40f0dc6558"},
{file = "SQLAlchemy-1.4.31-cp310-cp310-win32.whl", hash = "sha256:2e216c13ecc7fcdcbb86bb3225425b3ed338e43a8810c7089ddb472676124b9b"}, {file = "SQLAlchemy-1.4.32-cp310-cp310-win_amd64.whl", hash = "sha256:199dc6d0068753b6a8c0bd3aceb86a3e782df118260ebc1fa981ea31ee054674"},
{file = "SQLAlchemy-1.4.31-cp310-cp310-win_amd64.whl", hash = "sha256:e3a86b59b6227ef72ffc10d4b23f0fe994bef64d4667eab4fb8cd43de4223bec"}, {file = "SQLAlchemy-1.4.32-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:8e1e5d96b744a4f91163290b01045430f3f32579e46d87282449e5b14d27d4ac"},
{file = "SQLAlchemy-1.4.31-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:2fd4d3ca64c41dae31228b80556ab55b6489275fb204827f6560b65f95692cf3"}, {file = "SQLAlchemy-1.4.32-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:edfcf93fd92e2f9eef640b3a7a40db20fe3c1d7c2c74faa41424c63dead61b76"},
{file = "SQLAlchemy-1.4.31-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f22c040d196f841168b1456e77c30a18a3dc16b336ddbc5a24ce01ab4e95ae0"}, {file = "SQLAlchemy-1.4.32-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:04164e0063feb7aedd9d073db0fd496edb244be40d46ea1f0d8990815e4b8c34"},
{file = "SQLAlchemy-1.4.31-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:c0c7171aa5a57e522a04a31b84798b6c926234cb559c0939840c3235cf068813"}, {file = "SQLAlchemy-1.4.32-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5ba59761c19b800bc2e1c9324da04d35ef51e4ee9621ff37534bc2290d258f71"},
{file = "SQLAlchemy-1.4.31-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d046a9aeba9bc53e88a41e58beb72b6205abb9a20f6c136161adf9128e589db5"}, {file = "SQLAlchemy-1.4.32-cp36-cp36m-win32.whl", hash = "sha256:708973b5d9e1e441188124aaf13c121e5b03b6054c2df59b32219175a25aa13e"},
{file = "SQLAlchemy-1.4.31-cp36-cp36m-win32.whl", hash = "sha256:d86132922531f0dc5a4f424c7580a472a924dd737602638e704841c9cb24aea2"}, {file = "SQLAlchemy-1.4.32-cp36-cp36m-win_amd64.whl", hash = "sha256:316270e5867566376e69a0ac738b863d41396e2b63274616817e1d34156dff0e"},
{file = "SQLAlchemy-1.4.31-cp36-cp36m-win_amd64.whl", hash = "sha256:ca68c52e3cae491ace2bf39b35fef4ce26c192fd70b4cd90f040d419f70893b5"}, {file = "SQLAlchemy-1.4.32-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:9a0195af6b9050c9322a97cf07514f66fe511968e623ca87b2df5e3cf6349615"},
{file = "SQLAlchemy-1.4.31-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:cf2cd387409b12d0a8b801610d6336ee7d24043b6dd965950eaec09b73e7262f"}, {file = "SQLAlchemy-1.4.32-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7e4a3c0c3c596296b37f8427c467c8e4336dc8d50f8ed38042e8ba79507b2c9"},
{file = "SQLAlchemy-1.4.31-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb4b15fb1f0aafa65cbdc62d3c2078bea1ceecbfccc9a1f23a2113c9ac1191fa"}, {file = "SQLAlchemy-1.4.32-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:bca714d831e5b8860c3ab134c93aec63d1a4f493bed20084f54e3ce9f0a3bf99"},
{file = "SQLAlchemy-1.4.31-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:c317ddd7c586af350a6aef22b891e84b16bff1a27886ed5b30f15c1ed59caeaa"}, {file = "SQLAlchemy-1.4.32-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e9a680d9665f88346ed339888781f5236347933906c5a56348abb8261282ec48"},
{file = "SQLAlchemy-1.4.31-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3c7ed6c69debaf6198fadb1c16ae1253a29a7670bbf0646f92582eb465a0b999"}, {file = "SQLAlchemy-1.4.32-cp37-cp37m-win32.whl", hash = "sha256:9cb5698c896fa72f88e7ef04ef62572faf56809093180771d9be8d9f2e264a13"},
{file = "SQLAlchemy-1.4.31-cp37-cp37m-win32.whl", hash = "sha256:6a01ec49ca54ce03bc14e10de55dfc64187a2194b3b0e5ac0fdbe9b24767e79e"}, {file = "SQLAlchemy-1.4.32-cp37-cp37m-win_amd64.whl", hash = "sha256:8b9a395122770a6f08ebfd0321546d7379f43505882c7419d7886856a07caa13"},
{file = "SQLAlchemy-1.4.31-cp37-cp37m-win_amd64.whl", hash = "sha256:330eb45395874cc7787214fdd4489e2afb931bc49e0a7a8f9cd56d6e9c5b1639"}, {file = "SQLAlchemy-1.4.32-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:3f88a4ee192142eeed3fe173f673ea6ab1f5a863810a9d85dbf6c67a9bd08f97"},
{file = "SQLAlchemy-1.4.31-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:5e9c7b3567edbc2183607f7d9f3e7e89355b8f8984eec4d2cd1e1513c8f7b43f"}, {file = "SQLAlchemy-1.4.32-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dd93162615870c976dba43963a24bb418b28448fef584f30755990c134a06a55"},
{file = "SQLAlchemy-1.4.31-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:de85c26a5a1c72e695ab0454e92f60213b4459b8d7c502e0be7a6369690eeb1a"}, {file = "SQLAlchemy-1.4.32-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:5a2e73508f939175363d8a4be9dcdc84cf16a92578d7fa86e6e4ca0e6b3667b2"},
{file = "SQLAlchemy-1.4.31-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:975f5c0793892c634c4920057da0de3a48bbbbd0a5c86f5fcf2f2fedf41b76da"}, {file = "SQLAlchemy-1.4.32-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bfec934aac7f9fa95fc82147a4ba5db0a8bdc4ebf1e33b585ab8860beb10232f"},
{file = "SQLAlchemy-1.4.31-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d5c20c8415173b119762b6110af64448adccd4d11f273fb9f718a9865b88a99c"}, {file = "SQLAlchemy-1.4.32-cp38-cp38-win32.whl", hash = "sha256:bb42f9b259c33662c6a9b866012f6908a91731a419e69304e1261ba3ab87b8d1"},
{file = "SQLAlchemy-1.4.31-cp38-cp38-win32.whl", hash = "sha256:b35dca159c1c9fa8a5f9005e42133eed82705bf8e243da371a5e5826440e65ca"}, {file = "SQLAlchemy-1.4.32-cp38-cp38-win_amd64.whl", hash = "sha256:7ff72b3cc9242d1a1c9b84bd945907bf174d74fc2519efe6184d6390a8df478b"},
{file = "SQLAlchemy-1.4.31-cp38-cp38-win_amd64.whl", hash = "sha256:b7b20c88873675903d6438d8b33fba027997193e274b9367421e610d9da76c08"}, {file = "SQLAlchemy-1.4.32-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:5dc9801ae9884e822ba942ca493642fb50f049c06b6dbe3178691fce48ceb089"},
{file = "SQLAlchemy-1.4.31-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:85e4c244e1de056d48dae466e9baf9437980c19fcde493e0db1a0a986e6d75b4"}, {file = "SQLAlchemy-1.4.32-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4607d2d16330757818c9d6fba322c2e80b4b112ff24295d1343a80b876eb0ed"},
{file = "SQLAlchemy-1.4.31-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e79e73d5ee24196d3057340e356e6254af4d10e1fc22d3207ea8342fc5ffb977"}, {file = "SQLAlchemy-1.4.32-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:20e9eba7fd86ef52e0df25bea83b8b518dfdf0bce09b336cfe51671f52aaaa3f"},
{file = "SQLAlchemy-1.4.31-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:15a03261aa1e68f208e71ae3cd845b00063d242cbf8c87348a0c2c0fc6e1f2ac"}, {file = "SQLAlchemy-1.4.32-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:290cbdf19129ae520d4bdce392648c6fcdbee763bc8f750b53a5ab51880cb9c9"},
{file = "SQLAlchemy-1.4.31-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0ddc5e5ccc0160e7ad190e5c61eb57560f38559e22586955f205e537cda26034"}, {file = "SQLAlchemy-1.4.32-cp39-cp39-win32.whl", hash = "sha256:1bbac3e8293b34c4403d297e21e8f10d2a57756b75cff101dc62186adec725f5"},
{file = "SQLAlchemy-1.4.31-cp39-cp39-win32.whl", hash = "sha256:289465162b1fa1e7a982f8abe59d26a8331211cad4942e8031d2b7db1f75e649"}, {file = "SQLAlchemy-1.4.32-cp39-cp39-win_amd64.whl", hash = "sha256:b3f1d9b3aa09ab9adc7f8c4b40fc3e081eb903054c9a6f9ae1633fe15ae503b4"},
{file = "SQLAlchemy-1.4.31-cp39-cp39-win_amd64.whl", hash = "sha256:9e4fb2895b83993831ba2401b6404de953fdbfa9d7d4fa6a4756294a83bbc94f"}, {file = "SQLAlchemy-1.4.32.tar.gz", hash = "sha256:6fdd2dc5931daab778c2b65b03df6ae68376e028a3098eb624d0909d999885bc"},
{file = "SQLAlchemy-1.4.31.tar.gz", hash = "sha256:582b59d1e5780a447aada22b461e50b404a9dc05768da1d87368ad8190468418"}, ]
sqlalchemy-stubs = [
{file = "sqlalchemy-stubs-0.4.tar.gz", hash = "sha256:c665d6dd4482ef642f01027fa06c3d5e91befabb219dc71fc2a09e7d7695f7ae"},
{file = "sqlalchemy_stubs-0.4-py3-none-any.whl", hash = "sha256:5eec7aa110adf9b957b631799a72fef396b23ff99fe296df726645d01e312aa5"},
]
stack-data = [
{file = "stack_data-0.2.0-py3-none-any.whl", hash = "sha256:999762f9c3132308789affa03e9271bbbe947bf78311851f4d485d8402ed858e"},
{file = "stack_data-0.2.0.tar.gz", hash = "sha256:45692d41bd633a9503a5195552df22b583caf16f0b27c4e58c98d88c8b648e12"},
] ]
tinytag = [ tinytag = [
{file = "tinytag-1.7.0.tar.gz", hash = "sha256:513135c156e93026837a177ed336487f4fbdaa5c7efe8b8bfbeab9ce8f1404f7"}, {file = "tinytag-1.8.1.tar.gz", hash = "sha256:363ab3107831a5598b68aaa061aba915fb1c7b4254d770232e65d5db8487636d"},
]
toml = [
{file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"},
{file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"},
]
tomli = [
{file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"},
{file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"},
]
traitlets = [
{file = "traitlets-5.1.1-py3-none-any.whl", hash = "sha256:2d313cc50a42cd6c277e7d7dc8d4d7fedd06a2c215f78766ae7b1a66277e0033"},
{file = "traitlets-5.1.1.tar.gz", hash = "sha256:059f456c5a7c1c82b98c2e8c799f39c9b8128f6d0d46941ee118daace9eb70c7"},
]
typing-extensions = [
{file = "typing_extensions-4.1.1-py3-none-any.whl", hash = "sha256:21c85e0fe4b9a155d0799430b0ad741cdce7e359660ccbd8b530613e8df88ce2"},
{file = "typing_extensions-4.1.1.tar.gz", hash = "sha256:1a9462dcc3347a79b1f1c0271fbe79e844580bb598bafa1ed208b94da3cdcd42"},
]
wcwidth = [
{file = "wcwidth-0.2.5-py2.py3-none-any.whl", hash = "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784"},
{file = "wcwidth-0.2.5.tar.gz", hash = "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83"},
] ]

BIN
prof/combined.prof Normal file

Binary file not shown.

3833
prof/combined.svg Normal file

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

View File

@ -16,9 +16,15 @@ alembic = "^1.7.5"
psutil = "^5.9.0" psutil = "^5.9.0"
PyQtWebEngine = "^5.15.5" PyQtWebEngine = "^5.15.5"
pydub = "^0.25.1" pydub = "^0.25.1"
PyQt5-sip = "^12.9.0" PyQt5-sip = "^12.9.1"
[tool.poetry.dev-dependencies] [tool.poetry.dev-dependencies]
ipdb = "^0.13.9"
sqlalchemy-stubs = "^0.4"
PyQt5-stubs = "^5.15.2"
mypy = "^0.931"
pytest = "^7.0.1"
pytest-qt = "^4.0.2"
[build-system] [build-system]
requires = ["poetry-core>=1.0.0"] requires = ["poetry-core>=1.0.0"]

2
pytest.ini Normal file
View File

@ -0,0 +1,2 @@
[pytest]
addopts = -xls

72
test_helpers.py Normal file
View File

@ -0,0 +1,72 @@
from config import Config
from datetime import datetime, timedelta
from helpers import *
from models import Tracks
def test_fade_point():
test_track_path = "testdata/isa.mp3"
test_track_data = "testdata/isa.py"
audio_segment = get_audio_segment(test_track_path)
assert audio_segment
fade_at = fade_point(audio_segment)
# Get test data
with open(test_track_data) as f:
testdata = eval(f.read())
# Volume detection can vary, so ± 1 second is OK
assert fade_at < testdata['fade_at'] + 1000
assert fade_at > testdata['fade_at'] - 1000
def test_get_tags():
test_track_path = "testdata/mom.mp3"
test_track_data = "testdata/mom.py"
tags = get_tags(test_track_path)
# Get test data
with open(test_track_data) as f:
testdata = eval(f.read())
assert tags['artist'] == testdata['artist']
assert tags['title'] == testdata['title']
def test_get_relative_date():
assert get_relative_date(None) == "Never"
today_at_10 = datetime.now().replace(hour=10, minute=0)
today_at_11 = datetime.now().replace(hour=11, minute=0)
assert get_relative_date(today_at_10, today_at_11) == "10:00"
eight_days_ago = today_at_10 - timedelta(days=8)
assert get_relative_date(eight_days_ago, today_at_11) == "1 week, 1 day ago"
sixteen_days_ago = today_at_10 - timedelta(days=16)
assert get_relative_date(
sixteen_days_ago, today_at_11) == "2 weeks, 2 days ago"
def test_leading_silence():
test_track_path = "testdata/isa.mp3"
test_track_data = "testdata/isa.py"
audio_segment = get_audio_segment(test_track_path)
assert audio_segment
silence_at = leading_silence(audio_segment)
# Get test data
with open(test_track_data) as f:
testdata = eval(f.read())
# Volume detection can vary, so ± 1 second is OK
assert silence_at < testdata['leading_silence'] + 1000
assert silence_at > testdata['leading_silence'] - 1000
def test_ms_to_mmss():
assert ms_to_mmss(None) == "-"
assert ms_to_mmss(59600) == "0:59"
assert ms_to_mmss((5 * 60 * 1000) + 23000) == "5:23"

513
test_models.py Normal file
View File

@ -0,0 +1,513 @@
import os.path
from app.models import (
NoteColours,
Notes,
Playdates,
Playlists,
PlaylistTracks,
Tracks,
)
def test_notecolours_get_colour(session):
"""Create a colour record and retrieve all colours"""
note_colour = "#0bcdef"
NoteColours(session, substring="substring", colour=note_colour)
records = NoteColours.get_all(session)
assert len(records) == 1
record = records[0]
assert record.colour == note_colour
def test_notecolours_get_all(session):
"""Create two colour records and retrieve them all"""
note1_colour = "#1bcdef"
note2_colour = "#20ff00"
NoteColours(session, substring="note1", colour=note1_colour)
NoteColours(session, substring="note2", colour=note2_colour)
records = NoteColours.get_all(session)
assert len(records) == 2
assert note1_colour in [n.colour for n in records]
assert note2_colour in [n.colour for n in records]
def test_notecolours_get_colour_none(session):
note_colour = "#3bcdef"
NoteColours(session, substring="substring", colour=note_colour)
result = NoteColours.get_colour(session, "xyz")
assert result is None
def test_notecolours_get_colour_match(session):
note_colour = "#4bcdef"
nc = NoteColours(session, substring="sub", colour=note_colour)
assert nc
result = NoteColours.get_colour(session, "The substring")
assert result == note_colour
def test_notes_creation(session):
# We need a playlist
playlist = Playlists(session, "my playlist")
note_text = "note text"
note = Notes(session, playlist.id, 0, note_text)
assert note
notes = session.query(Notes).all()
assert len(notes) == 1
assert notes[0].note == note_text
def test_notes_delete(session):
# We need a playlist
playlist = Playlists(session, "my playlist")
note_text = "note text"
note = Notes(session, playlist.id, 0, note_text)
assert note
notes = session.query(Notes).all()
assert len(notes) == 1
assert notes[0].note == note_text
note.delete_note(session)
notes = session.query(Notes).all()
assert len(notes) == 0
def test_notes_update_row_only(session):
# We need a playlist
playlist = Playlists(session, "my playlist")
note_text = "note text"
note = Notes(session, playlist.id, 0, note_text)
new_row = 10
note.update_note(session, new_row)
notes = session.query(Notes).all()
assert len(notes) == 1
assert notes[0].row == new_row
def test_notes_update_text(session):
# We need a playlist
playlist = Playlists(session, "my playlist")
note_text = "note text"
note = Notes(session, playlist.id, 0, note_text)
new_text = "This is new"
new_row = 0
note.update_note(session, new_row, new_text)
notes = session.query(Notes).all()
assert len(notes) == 1
assert notes[0].note == new_text
assert notes[0].row == new_row
def test_playdates_add_playdate(session):
"""Test playdate and last_played retrieval"""
# We need a track
track_path = "/a/b/c"
track = Tracks(session, track_path)
playdate = Playdates(session, track.id)
assert playdate
last_played = Playdates.last_played(session, track.id)
assert abs((playdate.lastplayed - last_played).total_seconds()) < 2
def test_playdates_remove_track(session):
"""Test removing a track from a playdate"""
# We need a track
track_path = "/a/b/c"
track = Tracks(session, track_path)
playdate = Playdates(session, track.id)
Playdates.remove_track(session, track.id)
last_played = Playdates.last_played(session, track.id)
assert last_played is None
def test_playlist_create(session):
playlist = Playlists(session, "my playlist")
assert playlist
def test_playlist_add_note(session):
note_text = "my note"
note_row = 2
playlist = Playlists(session, "my playlist")
note = playlist.add_note(session, note_row, note_text)
assert len(playlist.notes) == 1
playlist_note = playlist.notes[0]
assert playlist_note.note == note_text
def test_playlist_add_track(session):
# We need a playlist
playlist = Playlists(session, "my playlist")
# We need a track
track_path = "/a/b/c"
track = Tracks(session, track_path)
row = 17
playlist.add_track(session, track.id, row)
assert len(playlist.tracks) == 1
playlist_track = playlist.tracks[row]
assert playlist_track.path == track_path
def test_playlist_tracks(session):
# We need a playlist
playlist = Playlists(session, "my playlist")
# We need two tracks
track1_path = "/a/b/c"
track1_row = 17
track1 = Tracks(session, track1_path)
track2_path = "/x/y/z"
track2_row = 29
track2 = Tracks(session, track2_path)
playlist.add_track(session, track1.id, track1_row)
playlist.add_track(session, track2.id, track2_row)
tracks = playlist.tracks
assert tracks[track1_row] == track1
assert tracks[track2_row] == track2
def test_playlist_notes(session):
# We need a playlist
playlist = Playlists(session, "my playlist")
# We need two notes
note1_text = "note1 text"
note1_row = 11
note1 = Notes(session, playlist.id, note1_row, note1_text)
note2_text = "note2 text"
note2_row = 19
note2 = Notes(session, playlist.id, note2_row, note2_text)
notes = playlist.notes
assert note1_text in [n.note for n in notes]
assert note1_row in [n.row for n in notes]
assert note2_text in [n.note for n in notes]
assert note2_row in [n.row for n in notes]
def test_playlist_open_and_close(session):
# We need a playlist
playlist = Playlists(session, "my playlist")
assert len(Playlists.get_open(session)) == 1
assert len(Playlists.get_closed(session)) == 0
playlist.close(session)
assert len(Playlists.get_open(session)) == 0
assert len(Playlists.get_closed(session)) == 1
playlist.mark_open(session)
assert len(Playlists.get_open(session)) == 1
assert len(Playlists.get_closed(session)) == 0
def test_playlist_get_all_and_by_id(session):
# We need two playlists
p1_name = "playlist one"
p2_name = "playlist two"
playlist1 = Playlists(session, p1_name)
_ = Playlists(session, p2_name)
all_playlists = Playlists.get_all(session)
assert len(all_playlists) == 2
assert p1_name in [p.name for p in all_playlists]
assert p2_name in [p.name for p in all_playlists]
assert Playlists.get_by_id(session, playlist1.id).name == p1_name
def test_playlist_remove_tracks(session):
# Need two playlists and three tracks
p1_name = "playlist one"
playlist1 = Playlists(session, p1_name)
p2_name = "playlist two"
playlist2 = Playlists(session, p2_name)
track1_path = "/a/b/c"
track1 = Tracks(session, track1_path)
track2_path = "/m/n/o"
track2 = Tracks(session, track2_path)
track3_path = "/x/y/z"
track3 = Tracks(session, track3_path)
# Add all tracks to both playlists
for p in [playlist1, playlist2]:
for t in [track1, track2, track3]:
p.add_track(session, t.id)
assert len(playlist1.tracks) == 3
assert len(playlist2.tracks) == 3
playlist1.remove_track(session, 1)
assert len(playlist1.tracks) == 2
# Check the track itself still exists
original_track = Tracks.get_by_id(session, track1.id)
assert original_track
playlist1.remove_all_tracks(session)
assert len(playlist1.tracks) == 0
assert len(playlist2.tracks) == 3
def test_playlist_get_track_playlists(session):
# Need two playlists and two tracks
p1_name = "playlist one"
playlist1 = Playlists(session, p1_name)
p2_name = "playlist two"
playlist2 = Playlists(session, p2_name)
track1_path = "/a/b/c"
track1 = Tracks(session, track1_path)
track2_path = "/m/n/o"
track2 = Tracks(session, track2_path)
# Put track1 in both playlists, track2 only in playlist1
playlist1.add_track(session, track1.id)
playlist2.add_track(session, track1.id)
playlist1.add_track(session, track2.id)
playlists_track1 = track1.playlists
playlists_track2 = track2.playlists
assert p1_name in [a.playlist.name for a in playlists_track1]
assert p2_name in [a.playlist.name for a in playlists_track1]
assert p1_name in [a.playlist.name for a in playlists_track2]
assert p2_name not in [a.playlist.name for a in playlists_track2]
def test_playlist_move_track(session):
# We need two playlists
p1_name = "playlist one"
p2_name = "playlist two"
playlist1 = Playlists(session, p1_name)
playlist2 = Playlists(session, p2_name)
# Need two tracks
track1_row = 17
track1_path = "/a/b/c"
track1 = Tracks(session, track1_path)
track2_row = 29
track2_path = "/m/n/o"
track2 = Tracks(session, track2_path)
# Add both to playlist1 and check
playlist1.add_track(session, track1.id, track1_row)
playlist1.add_track(session, track2.id, track2_row)
tracks = playlist1.tracks
assert tracks[track1_row] == track1
assert tracks[track2_row] == track2
# Move track2 to playlist2 and check
playlist1.move_track(session, [track2_row], playlist2)
tracks1 = playlist1.tracks
tracks2 = playlist2.tracks
assert len(tracks1) == 1
assert len(tracks2) == 1
assert tracks1[track1_row] == track1
assert tracks2[0] == track2
def test_tracks_get_all_paths(session):
# Need two tracks
track1_path = "/a/b/c"
_ = Tracks(session, track1_path)
track2_path = "/m/n/o"
_ = Tracks(session, track2_path)
result = Tracks.get_all_paths(session)
assert track1_path in result
assert track2_path in result
def test_tracks_get_all_tracks(session):
# Need two tracks
track1_path = "/a/b/c"
track1 = Tracks(session, track1_path)
track2_path = "/m/n/o"
track2 = Tracks(session, track2_path)
result = Tracks.get_all_tracks(session)
assert track1_path in [a.path for a in result]
assert track2_path in [a.path for a in result]
def test_tracks_get_or_create(session):
track1_path = "/a/b/c"
track1 = Tracks.get_or_create(session, track1_path)
assert track1.path == track1_path
track2 = Tracks.get_or_create(session, track1_path)
assert track1 is track2
def test_tracks_by_filename(session):
track1_path = "/a/b/c"
track1 = Tracks(session, track1_path)
assert Tracks.get_by_filename(
session, os.path.basename(track1_path)
) is track1
def test_tracks_by_path(session):
track1_path = "/a/b/c"
track1 = Tracks(session, track1_path)
assert Tracks.get_by_path(session, track1_path) is track1
def test_tracks_by_id(session):
track1_path = "/a/b/c"
track1 = Tracks(session, track1_path)
assert Tracks.get_by_id(session, track1.id) is track1
def test_tracks_rescan(session):
# Get test track
test_track_path = "./testdata/isa.mp3"
test_track_data = "./testdata/isa.py"
track = Tracks(session, test_track_path)
track.rescan(session)
# Get test data
with open(test_track_data) as f:
testdata = eval(f.read())
# Re-read the track
track_read = Tracks.get_by_path(session, test_track_path)
assert track_read.duration == testdata['duration']
assert track_read.start_gap == testdata['leading_silence']
# Silence detection can vary, so ± 1 second is OK
assert track_read.fade_at < testdata['fade_at'] + 1000
assert track_read.fade_at > testdata['fade_at'] - 1000
assert track_read.silence_at < testdata['trailing_silence'] + 1000
assert track_read.silence_at > testdata['trailing_silence'] - 1000
def test_tracks_remove_by_path(session):
track1_path = "/a/b/c"
track1 = Tracks(session, track1_path)
assert len(Tracks.get_all_tracks(session)) == 1
Tracks.remove_by_path(session, track1_path)
assert len(Tracks.get_all_tracks(session)) == 0
def test_tracks_search_artists(session):
track1_path = "/a/b/c"
track1_artist = "Artist One"
track1 = Tracks(session, track1_path)
track1.artist = track1_artist
track2_path = "/m/n/o"
track2_artist = "Artist Two"
track2 = Tracks(session, track2_path)
track2.artist = track2_artist
session.commit()
x = Tracks.get_all_tracks(session)
artist_first_word = track1_artist.split()[0].lower()
assert len(Tracks.search_artists(session, artist_first_word)) == 2
assert len(Tracks.search_artists(session, track1_artist)) == 1
def test_tracks_search_titles(session):
track1_path = "/a/b/c"
track1_title = "Title One"
track1 = Tracks(session, track1_path)
track1.title = track1_title
track2_path = "/m/n/o"
track2_title = "Title Two"
track2 = Tracks(session, track2_path)
track2.title = track2_title
session.commit()
x = Tracks.get_all_tracks(session)
title_first_word = track1_title.split()[0].lower()
assert len(Tracks.search_titles(session, title_first_word)) == 2
assert len(Tracks.search_titles(session, track1_title)) == 1
def test_tracks_update_lastplayed(session):
track1_path = "/a/b/c"
track1 = Tracks(session, track1_path)
assert track1.lastplayed is None
track1.update_lastplayed(session, track1.id)
assert track1.lastplayed is not None
def test_tracks_update_info(session):
path = "/a/b/c"
artist = "The Beatles"
title = "Help!"
newinfo = "abcdef"
track1 = Tracks(session, path)
track1.artist = artist
track1.title = title
test1 = Tracks.get_by_id(session, track1.id)
assert test1.artist == artist
assert test1.title == title
assert test1.path == path
track1.path = newinfo
test2 = Tracks.get_by_id(session, track1.id)
assert test2.artist == artist
assert test2.title == title
assert test2.path == newinfo
track1.artist = newinfo
test2 = Tracks.get_by_id(session, track1.id)
assert test2.artist == newinfo
assert test2.title == title
assert test2.path == newinfo
track1.title = newinfo
test3 = Tracks.get_by_id(session, track1.id)
assert test3.artist == newinfo
assert test3.title == newinfo
assert test3.path == newinfo

296
test_playlists.py Normal file
View File

@ -0,0 +1,296 @@
from PyQt5.QtCore import Qt
from app import playlists
from app import models
from app import musicmuster
from app import dbconfig
def seed2tracks(session):
tracks = [
{
"path": "testdata/isa.mp3",
"title": "I'm so afraid",
"artist": "Fleetwood Mac",
"duration": 263000,
"start_gap": 60,
"fade_at": 236263,
"silence_at": 260343,
"mtime": 371900000,
},
{
"path": "testdata/mom.mp3",
"title": "Man of Mystery",
"artist": "The Shadows",
"duration": 120000,
"start_gap": 70,
"fade_at": 115000,
"silence_at": 118000,
"mtime": 1642760000,
},
]
for track in tracks:
db_track = models.Tracks(session=session, **track)
session.add(db_track)
session.commit()
def test_init(qtbot, session):
"""Just check we can create a playlist_tab"""
playlist = models.Playlists(session, "my playlist")
playlist_tab = playlists.PlaylistTab(None, session, playlist.id)
assert playlist_tab
def test_save_and_restore(qtbot, session):
"""Playlist with one track, one note, save and restore"""
# Create playlist
playlist = models.Playlists(session, "my playlist")
playlist_tab = playlists.PlaylistTab(None, session, playlist.id)
# Insert a note
note_text = "my note"
note_row = 7
note = models.Notes(session, playlist.id, note_row, note_text)
playlist_tab._insert_note(session, note)
# Add a track
track_path = "/a/b/c"
track = models.Tracks(session, track_path)
# Inserting the track will also save the playlist
playlist_tab.insert_track(session, track)
# We need to commit the session before re-querying
session.commit()
# Retrieve playlist
all_playlists = playlists.Playlists.get_open(session)
assert len(all_playlists) == 1
retrieved_playlist = all_playlists[0]
paths = [a.path for a in retrieved_playlist.tracks.values()]
assert track_path in paths
notes = [a.note for a in retrieved_playlist.notes]
assert note_text in notes
def test_meta_all_clear(qtbot, session):
# Create playlist
playlist = models.Playlists(session, "my playlist")
playlist_tab = playlists.PlaylistTab(None, session, playlist.id)
# Add some tracks
# Need to commit session after each one so that new row is found
# for subsequent inserts
track1_path = "/a/b/c"
track1 = models.Tracks(session, track1_path)
playlist_tab.insert_track(session, track1)
session.commit()
track2_path = "/d/e/f"
track2 = models.Tracks(session, track2_path)
playlist_tab.insert_track(session, track2)
session.commit()
track3_path = "/h/i/j"
track3 = models.Tracks(session, track3_path)
playlist_tab.insert_track(session, track3)
session.commit()
assert playlist_tab._get_current_track_row() is None
assert playlist_tab._get_next_track_row() is None
assert playlist_tab._get_notes_rows() == []
assert playlist_tab._get_played_track_rows() == []
assert len(playlist_tab._get_unreadable_track_rows()) == 3
def test_meta(qtbot, session):
# Create playlist
playlist = playlists.Playlists(session, "my playlist")
playlist_tab = playlists.PlaylistTab(None, session, playlist.id)
# Add some tracks
track1_path = "/a/b/c"
track1 = models.Tracks(session, track1_path)
playlist_tab.insert_track(session, track1)
session.commit()
track2_path = "/d/e/f"
track2 = models.Tracks(session, track2_path)
playlist_tab.insert_track(session, track2)
session.commit()
track3_path = "/h/i/j"
track3 = models.Tracks(session, track3_path)
playlist_tab.insert_track(session, track3)
session.commit()
assert len(playlist_tab._get_unreadable_track_rows()) == 3
assert playlist_tab._get_played_track_rows() == []
assert playlist_tab._get_current_track_row() is None
assert playlist_tab._get_next_track_row() is None
assert playlist_tab._get_notes_rows() == []
playlist_tab._set_played_row(0)
assert playlist_tab._get_played_track_rows() == [0]
assert playlist_tab._get_current_track_row() is None
assert playlist_tab._get_next_track_row() is None
assert playlist_tab._get_notes_rows() == []
# Add a note
note_text = "my note"
note_row = 7 # will be added as row 3
note = models.Notes(session, playlist.id, note_row, note_text)
playlist_tab._insert_note(session, note)
assert playlist_tab._get_played_track_rows() == [0]
assert playlist_tab._get_current_track_row() is None
assert playlist_tab._get_next_track_row() is None
assert playlist_tab._get_notes_rows() == [3]
playlist_tab._set_next_track_row(1)
assert playlist_tab._get_played_track_rows() == [0]
assert playlist_tab._get_current_track_row() is None
assert playlist_tab._get_next_track_row() == 1
assert playlist_tab._get_notes_rows() == [3]
playlist_tab._set_current_track_row(2)
assert playlist_tab._get_played_track_rows() == [0]
assert playlist_tab._get_current_track_row() == 2
assert playlist_tab._get_next_track_row() == 1
assert playlist_tab._get_notes_rows() == [3]
playlist_tab._clear_played_row_status(0)
assert playlist_tab._get_played_track_rows() == []
assert playlist_tab._get_current_track_row() == 2
assert playlist_tab._get_next_track_row() == 1
assert playlist_tab._get_notes_rows() == [3]
playlist_tab._meta_clear_next()
assert playlist_tab._get_played_track_rows() == []
assert playlist_tab._get_current_track_row() == 2
assert playlist_tab._get_next_track_row() is None
assert playlist_tab._get_notes_rows() == [3]
playlist_tab._clear_current_track_row()
assert playlist_tab._get_played_track_rows() == []
assert playlist_tab._get_current_track_row() is None
assert playlist_tab._get_next_track_row() is None
assert playlist_tab._get_notes_rows() == [3]
# Test clearing again has no effect
playlist_tab._clear_current_track_row()
assert playlist_tab._get_played_track_rows() == []
assert playlist_tab._get_current_track_row() is None
assert playlist_tab._get_next_track_row() is None
assert playlist_tab._get_notes_rows() == [3]
def test_clear_next(qtbot, session):
# Create playlist
playlist = models.Playlists(session, "my playlist")
playlist_tab = playlists.PlaylistTab(None, session, playlist.id)
# Add some tracks
track1_path = "/a/b/c"
track1 = models.Tracks(session, track1_path)
playlist_tab.insert_track(session, track1)
session.commit()
track2_path = "/d/e/f"
track2 = models.Tracks(session, track2_path)
playlist_tab.insert_track(session, track2)
session.commit()
playlist_tab._set_next_track_row(1)
assert playlist_tab._get_next_track_row() == 1
playlist_tab.clear_next(session)
assert playlist_tab._get_next_track_row() is None
def test_get_selected_row(qtbot, monkeypatch, session):
monkeypatch.setattr(musicmuster, "Session", session)
monkeypatch.setattr(playlists, "Session", session)
# Create playlist and playlist_tab
window = musicmuster.Window()
playlist = models.Playlists(session, "test playlist")
playlist_tab = playlists.PlaylistTab(window, session, playlist.id)
# Add some tracks
track1_path = "/a/b/c"
track1 = models.Tracks(session, track1_path)
playlist_tab.insert_track(session, track1)
session.commit()
track2_path = "/d/e/f"
track2 = models.Tracks(session, track2_path)
playlist_tab.insert_track(session, track2)
session.commit()
qtbot.addWidget(playlist_tab)
with qtbot.waitExposed(window):
window.show()
row0_item0 = playlist_tab.item(0, 0)
assert row0_item0 is not None
rect = playlist_tab.visualItemRect(row0_item0)
qtbot.mouseClick(
playlist_tab.viewport(), Qt.LeftButton, pos=rect.center()
)
row_number = playlist_tab.get_selected_row()
assert row_number == 0
def test_set_next(qtbot, monkeypatch, session):
monkeypatch.setattr(musicmuster, "Session", session)
monkeypatch.setattr(playlists, "Session", session)
seed2tracks(session)
playlist_name = "test playlist"
# Create testing playlist
window = musicmuster.Window()
playlist = models.Playlists(session, playlist_name)
playlist_tab = playlists.PlaylistTab(window, session, playlist.id)
idx = window.tabPlaylist.addTab(playlist_tab, playlist_name)
window.tabPlaylist.setCurrentIndex(idx)
qtbot.addWidget(playlist_tab)
# Add some tracks
track1 = models.Tracks.get_by_filename(session, "isa.mp3")
track1_title = track1.title
assert track1_title
playlist_tab.insert_track(session, track1)
session.commit()
track2 = models.Tracks.get_by_filename(session, "mom.mp3")
playlist_tab.insert_track(session, track2)
with qtbot.waitExposed(window):
window.show()
row0_item2 = playlist_tab.item(0, 2)
assert row0_item2 is not None
rect = playlist_tab.visualItemRect(row0_item2)
qtbot.mouseClick(
playlist_tab.viewport(), Qt.LeftButton, pos=rect.center()
)
selected_title = playlist_tab.get_selected_title()
assert selected_title == track1_title
qtbot.keyPress(playlist_tab.viewport(), "N",
modifier=Qt.ControlModifier)
qtbot.wait(1000)
def test_kae(monkeypatch, session):
# monkeypatch.setattr(dbconfig, "Session", session)
monkeypatch.setattr(musicmuster, "Session", session)
musicmuster.Window.kae()
# monkeypatch.setattr(musicmuster, "Session", session)
# monkeypatch.setattr(dbconfig, "Session", session)
# monkeypatch.setattr(models, "Session", session)
# monkeypatch.setattr(playlists, "Session", session)

8
testdata/isa.py vendored Normal file
View File

@ -0,0 +1,8 @@
# Measurements for isa.{mp3,flac} (milliseconds)
{
"leading_silence": 60,
"fade_at": 236163,
"trailing_silence": 259373,
"duration": 262533,
}

6
testdata/mom.py vendored Normal file
View File

@ -0,0 +1,6 @@
# Tags for mom.py
{
"title": "Man of Mystery",
"artist": "The Shadows",
}