Compare commits
No commits in common. "a2fb6baba88b2347fb4aae57520f91cf8da02f4a" and "03735c24568a3c2f1c0a72b5f208e9ccd2c7f922" have entirely different histories.
a2fb6baba8
...
03735c2456
10
.envrc
10
.envrc
@ -1,11 +1 @@
|
|||||||
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}"
|
|
||||||
|
|||||||
@ -1,5 +0,0 @@
|
|||||||
<component name="ProjectCodeStyleConfiguration">
|
|
||||||
<state>
|
|
||||||
<option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" />
|
|
||||||
</state>
|
|
||||||
</component>
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
<component name="ProjectDictionaryState">
|
|
||||||
<dictionary name="kae" />
|
|
||||||
</component>
|
|
||||||
@ -1,7 +1,4 @@
|
|||||||
<?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) (2)" project-jdk-type="Python SDK" />
|
||||||
<component name="PythonCompatibilityInspectionAdvertiser">
|
|
||||||
<option name="version" value="3" />
|
|
||||||
</component>
|
|
||||||
</project>
|
</project>
|
||||||
@ -1,10 +1,8 @@
|
|||||||
<?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$" />
|
||||||
<sourceFolder url="file://$MODULE_DIR$/app" isTestSource="false" />
|
<orderEntry type="inheritedJdk" />
|
||||||
</content>
|
|
||||||
<orderEntry type="jdk" jdkName="Poetry (musicmuster) (2)" jdkType="Python SDK" />
|
|
||||||
<orderEntry type="sourceFolder" forTests="false" />
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
</component>
|
</component>
|
||||||
<component name="PyDocumentationSettings">
|
<component name="PyDocumentationSettings">
|
||||||
|
|||||||
@ -39,10 +39,8 @@ 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 = SET
|
sqlalchemy.url = mysql+mysqldb://musicmuster:musicmuster@localhost/musicmuster_dev
|
||||||
# sqlalchemy.url = mysql+mysqldb://musicmuster:musicmuster@localhost/musicmuster_prod
|
# 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
|
||||||
|
|||||||
@ -3,43 +3,29 @@ 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 = "#7eca8f"
|
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"
|
||||||
@ -50,14 +36,18 @@ 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_v2" # noqa E501
|
MYSQL_CONNECT = os.environ.get('MYSQL_CONNECT') or "mysql+mysqldb://musicmuster:musicmuster@localhost/musicmuster_dev" # noqa E501
|
||||||
NORMALISE_ON_IMPORT = True
|
NORMALISE_ON_IMPORT = True
|
||||||
NOTE_TIME_FORMAT = "%H:%M:%S"
|
NOTE_COLOURS = {
|
||||||
|
'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"
|
||||||
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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
199
app/helpers.py
199
app/helpers.py
@ -1,78 +1,11 @@
|
|||||||
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 ask_yes_no(title: str, question: str) -> bool:
|
def get_relative_date(past_date, reference_date=None):
|
||||||
"""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.
|
||||||
|
|
||||||
@ -92,11 +25,6 @@ def get_relative_date(past_date: datetime, reference_date: datetime = 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
|
||||||
@ -112,37 +40,55 @@ def get_relative_date(past_date: datetime, reference_date: datetime = None) \
|
|||||||
return f"{weeks} {weeks_str}, {days} {days_str} ago"
|
return f"{weeks} {weeks_str}, {days} {days_str} ago"
|
||||||
|
|
||||||
|
|
||||||
def leading_silence(
|
def open_in_audacity(path):
|
||||||
audio_segment: AudioSegment,
|
|
||||||
silence_threshold: int = Config.DBFS_SILENCE,
|
|
||||||
chunk_size: int = Config.AUDIO_SEGMENT_CHUNK_SIZE):
|
|
||||||
"""
|
"""
|
||||||
Returns the millisecond/index that the leading silence ends.
|
Open passed file in Audacity
|
||||||
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
|
Return True if apparently opened successfully, else False
|
||||||
"""
|
"""
|
||||||
|
|
||||||
trim_ms: int = 0 # ms
|
# Return if audacity not running
|
||||||
assert chunk_size > 0 # to avoid infinite loop
|
if "audacity" not in [i.name() for i in psutil.process_iter()]:
|
||||||
while (
|
return False
|
||||||
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
|
to_pipe = '/tmp/audacity_script_pipe.to.' + str(os.getuid())
|
||||||
return min(trim_ms, len(audio_segment))
|
from_pipe = '/tmp/audacity_script_pipe.from.' + str(os.getuid())
|
||||||
|
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 ms_to_mmss(ms: int, decimals: int = 0, negative: bool = False) -> str:
|
def show_warning(title, msg):
|
||||||
"""Convert milliseconds to mm:ss"""
|
"Display a warning to user"
|
||||||
|
|
||||||
minutes: int
|
QMessageBox.warning(None, title, msg, buttons=QMessageBox.Cancel)
|
||||||
remainder: int
|
|
||||||
seconds: float
|
|
||||||
|
|
||||||
|
|
||||||
|
def ms_to_mmss(ms, decimals=0, negative=False):
|
||||||
if not ms:
|
if not ms:
|
||||||
return "-"
|
return "-"
|
||||||
sign = ""
|
sign = ""
|
||||||
@ -161,66 +107,3 @@ def ms_to_mmss(ms: int, decimals: int = 0, negative: bool = False) -> str:
|
|||||||
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)
|
|
||||||
|
|||||||
10
app/log.py
10
app/log.py
@ -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
|
||||||
local_filter = LevelTagFilter()
|
filter = LevelTagFilter()
|
||||||
syslog.addFilter(local_filter)
|
syslog.addFilter(filter)
|
||||||
stderr.addFilter(local_filter)
|
stderr.addFilter(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():
|
||||||
return 1 / 0
|
1 / 0
|
||||||
|
|
||||||
f()
|
f()
|
||||||
|
|||||||
666
app/model.py
Normal file
666
app/model.py
Normal file
@ -0,0 +1,666 @@
|
|||||||
|
#!/usr/bin/python3
|
||||||
|
|
||||||
|
import os.path
|
||||||
|
import re
|
||||||
|
|
||||||
|
import sqlalchemy
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
|
from sqlalchemy import (
|
||||||
|
Boolean,
|
||||||
|
Column,
|
||||||
|
DateTime,
|
||||||
|
Float,
|
||||||
|
ForeignKey,
|
||||||
|
Integer,
|
||||||
|
String,
|
||||||
|
func
|
||||||
|
)
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.orm.exc import MultipleResultsFound, NoResultFound
|
||||||
|
from sqlalchemy.orm import relationship, sessionmaker
|
||||||
|
|
||||||
|
from config import Config
|
||||||
|
from log import DEBUG, ERROR
|
||||||
|
|
||||||
|
# Create session at the global level as per
|
||||||
|
# https://docs.sqlalchemy.org/en/13/orm/session_basics.html
|
||||||
|
|
||||||
|
# Set up database connection
|
||||||
|
engine = sqlalchemy.create_engine(f"{Config.MYSQL_CONNECT}?charset=utf8",
|
||||||
|
encoding='utf-8',
|
||||||
|
echo=Config.DISPLAY_SQL,
|
||||||
|
pool_pre_ping=True)
|
||||||
|
Base = declarative_base()
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
|
||||||
|
# Create a Session factory
|
||||||
|
Session = sessionmaker(bind=engine)
|
||||||
|
|
||||||
|
|
||||||
|
# Database classes
|
||||||
|
class NoteColours(Base):
|
||||||
|
__tablename__ = 'notecolours'
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
substring = Column(String(256), index=False)
|
||||||
|
hexcolour = Column(String(6), index=False)
|
||||||
|
enabled = Column(Boolean, default=True, index=True)
|
||||||
|
is_regex = Column(Boolean, default=False, index=False)
|
||||||
|
is_casesensitive = Column(Boolean, default=False, index=False)
|
||||||
|
order = Column(Integer, index=True)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return (
|
||||||
|
f"<NoteColour(id={self.id}, substring={self.substring}, colour={self.hexcolour}>"
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_colour(session, text):
|
||||||
|
"""
|
||||||
|
Parse text and return colour string if match, else None
|
||||||
|
|
||||||
|
Currently ignore is_regex and is_casesensitive
|
||||||
|
"""
|
||||||
|
|
||||||
|
for rec in (
|
||||||
|
session.query(NoteColours)
|
||||||
|
.filter(NoteColours.enabled == True)
|
||||||
|
.order_by(NoteColours.order)
|
||||||
|
.all()
|
||||||
|
):
|
||||||
|
if rec.is_regex:
|
||||||
|
if rec.is_casesensitive:
|
||||||
|
p = re.compile(rec.substring)
|
||||||
|
else:
|
||||||
|
p = re.compile(rec.substring, re.IGNORECASE)
|
||||||
|
if p.match(text):
|
||||||
|
return '#' + rec.hexcolour
|
||||||
|
else:
|
||||||
|
if rec.is_casesensitive:
|
||||||
|
if rec.substring in text:
|
||||||
|
return '#' + rec.hexcolour
|
||||||
|
else:
|
||||||
|
if rec.substring.lower() in text.lower():
|
||||||
|
return '#' + rec.hexcolour
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class Notes(Base):
|
||||||
|
__tablename__ = 'notes'
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
playlist_id = Column(Integer, ForeignKey('playlists.id'))
|
||||||
|
playlist = relationship("Playlists", back_populates="notes")
|
||||||
|
row = Column(Integer, nullable=False)
|
||||||
|
note = Column(String(256), index=False)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return (
|
||||||
|
f"<Note(id={self.id}, row={self.row}, note={self.note}>"
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def add_note(session, playlist_id, row, text):
|
||||||
|
"Add note"
|
||||||
|
|
||||||
|
DEBUG(f"add_note(playlist_id={playlist_id}, row={row}, text={text})")
|
||||||
|
note = Notes()
|
||||||
|
note.playlist_id = playlist_id
|
||||||
|
note.row = row
|
||||||
|
note.note = text
|
||||||
|
session.add(note)
|
||||||
|
session.commit()
|
||||||
|
return note
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def delete_note(session, id):
|
||||||
|
"Delete note"
|
||||||
|
|
||||||
|
DEBUG(f"delete_note(id={id}")
|
||||||
|
|
||||||
|
session.query(Notes).filter(Notes.id == id).delete()
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def update_note(cls, session, id, row, text=None):
|
||||||
|
"""
|
||||||
|
Update note details. If text=None, don't change text.
|
||||||
|
"""
|
||||||
|
|
||||||
|
DEBUG(f"Notes.update_note(id={id}, row={row}, text={text})")
|
||||||
|
|
||||||
|
note = session.query(cls).filter(cls.id == id).one()
|
||||||
|
note.row = row
|
||||||
|
if text:
|
||||||
|
note.note = text
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
class Playdates(Base):
|
||||||
|
__tablename__ = 'playdates'
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
lastplayed = Column(DateTime, index=True, default=None)
|
||||||
|
track_id = Column(Integer, ForeignKey('tracks.id'))
|
||||||
|
tracks = relationship("Tracks", back_populates="playdates")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def add_playdate(session, track):
|
||||||
|
"Record that track was played"
|
||||||
|
|
||||||
|
DEBUG(f"add_playdate(track={track})")
|
||||||
|
pd = Playdates()
|
||||||
|
pd.lastplayed = datetime.now()
|
||||||
|
pd.track_id = track.id
|
||||||
|
session.add(pd)
|
||||||
|
track.update_lastplayed(session, track.id)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def last_played(session, track_id):
|
||||||
|
"Return datetime track last played or None"
|
||||||
|
|
||||||
|
last_played = session.query(Playdates.lastplayed).filter(
|
||||||
|
(Playdates.track_id == track_id)
|
||||||
|
).order_by(Playdates.lastplayed.desc()).first()
|
||||||
|
if last_played:
|
||||||
|
return last_played[0]
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def remove_track(session, track_id):
|
||||||
|
"""
|
||||||
|
Remove all records of track_id
|
||||||
|
"""
|
||||||
|
|
||||||
|
session.query(Playdates).filter(
|
||||||
|
Playdates.track_id == track_id,
|
||||||
|
).delete()
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
class Playlists(Base):
|
||||||
|
"""
|
||||||
|
Usage:
|
||||||
|
|
||||||
|
pl = session.query(Playlists).filter(Playlists.id == 1).one()
|
||||||
|
|
||||||
|
pl
|
||||||
|
<Playlist(id=1, name=Default>
|
||||||
|
|
||||||
|
pl.tracks
|
||||||
|
[<__main__.PlaylistTracks at 0x7fcd20181c18>,
|
||||||
|
<__main__.PlaylistTracks at 0x7fcd20181c88>,
|
||||||
|
<__main__.PlaylistTracks at 0x7fcd20181be0>,
|
||||||
|
<__main__.PlaylistTracks at 0x7fcd20181c50>]
|
||||||
|
|
||||||
|
[a.tracks for a in pl.tracks]
|
||||||
|
[<Track(id=3992, title=Yesterday Man, artist=Various, path=/h[...]
|
||||||
|
<Track(id=2238, title=These Boots Are Made For Walkin', arti[...]
|
||||||
|
<Track(id=3837, title=Babe, artist=Various, path=/home/kae/m[...]
|
||||||
|
<Track(id=2332, title=Such Great Heights - Remastered, artis[...]]
|
||||||
|
|
||||||
|
glue = PlaylistTracks(row=5)
|
||||||
|
|
||||||
|
tr = session.query(Tracks).filter(Tracks.id == 676).one()
|
||||||
|
|
||||||
|
tr
|
||||||
|
<Track(id=676, title=Seven Nation Army, artist=White Stripes,
|
||||||
|
path=/home/kae/music/White Stripes/Elephant/01. Seven Nation Army.flac>
|
||||||
|
|
||||||
|
glue.track_id = tr.id
|
||||||
|
|
||||||
|
pl.tracks.append(glue)
|
||||||
|
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
[a.tracks for a in pl.tracks]
|
||||||
|
[<Track(id=3992, title=Yesterday Man, artist=Various, path=/h[...]
|
||||||
|
<Track(id=2238, title=These Boots Are Made For Walkin', arti[...]
|
||||||
|
<Track(id=3837, title=Babe, artist=Various, path=/home/kae/m[...]
|
||||||
|
<Track(id=2332, title=Such Great Heights - Remastered, artis[...]
|
||||||
|
<Track(id=676, title=Seven Nation Army, artist=White Stripes[...]]
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "playlists"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
name = Column(String(32), nullable=False, unique=True)
|
||||||
|
last_used = Column(DateTime, default=None, nullable=True)
|
||||||
|
loaded = Column(Boolean, default=True)
|
||||||
|
notes = relationship("Notes",
|
||||||
|
order_by="Notes.row",
|
||||||
|
back_populates="playlist")
|
||||||
|
tracks = relationship("PlaylistTracks",
|
||||||
|
order_by="PlaylistTracks.row",
|
||||||
|
back_populates="playlists")
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return (f"<Playlists(id={self.id}, name={self.name}>")
|
||||||
|
|
||||||
|
def add_track(self, session, track, row=None):
|
||||||
|
"""
|
||||||
|
Add track to playlist at given row.
|
||||||
|
If row=None, add to end of playlist
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not row:
|
||||||
|
row = PlaylistTracks.new_row(session, self.id)
|
||||||
|
|
||||||
|
DEBUG(f"Playlists:add_track({session=}, {track=}, {row=})")
|
||||||
|
|
||||||
|
glue = PlaylistTracks(row=row)
|
||||||
|
glue.track_id = track.id
|
||||||
|
self.tracks.append(glue)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
def close(self, session):
|
||||||
|
"Record playlist as no longer loaded"
|
||||||
|
|
||||||
|
self.loaded = False
|
||||||
|
session.add(self)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_all_closed_playlists(session):
|
||||||
|
"Returns a list of all playlists not currently open"
|
||||||
|
|
||||||
|
return (
|
||||||
|
session.query(Playlists)
|
||||||
|
.filter(
|
||||||
|
(Playlists.loaded == False) | # noqa E712
|
||||||
|
(Playlists.loaded == None)
|
||||||
|
)
|
||||||
|
.order_by(Playlists.last_used.desc())
|
||||||
|
).all()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_all_playlists(session):
|
||||||
|
"Returns a list of all playlists"
|
||||||
|
|
||||||
|
return session.query(Playlists).all()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_last_used(session):
|
||||||
|
"""
|
||||||
|
Return a list of playlists marked "loaded", ordered by loaded date.
|
||||||
|
"""
|
||||||
|
|
||||||
|
return (
|
||||||
|
session.query(Playlists)
|
||||||
|
.filter(Playlists.loaded == True) # noqa E712
|
||||||
|
.order_by(Playlists.last_used.desc())
|
||||||
|
).all()
|
||||||
|
|
||||||
|
def get_notes(self):
|
||||||
|
return [a.note for a in self.notes]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_playlist(session, playlist_id):
|
||||||
|
return (
|
||||||
|
session.query(Playlists)
|
||||||
|
.filter(
|
||||||
|
Playlists.id == playlist_id # noqa E712
|
||||||
|
)
|
||||||
|
).one()
|
||||||
|
|
||||||
|
def get_tracks(self):
|
||||||
|
return [a.tracks for a in self.tracks]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def new(session, name):
|
||||||
|
DEBUG(f"Playlists.new(name={name})")
|
||||||
|
playlist = Playlists()
|
||||||
|
playlist.name = name
|
||||||
|
session.add(playlist)
|
||||||
|
session.commit()
|
||||||
|
return playlist
|
||||||
|
|
||||||
|
def open(self, session):
|
||||||
|
"Mark playlist as loaded and used now"
|
||||||
|
|
||||||
|
self.loaded = True
|
||||||
|
self.last_used = datetime.now()
|
||||||
|
session.add(self)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
class PlaylistTracks(Base):
|
||||||
|
__tablename__ = 'playlisttracks'
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
playlist_id = Column(Integer, ForeignKey('playlists.id'), primary_key=True)
|
||||||
|
track_id = Column(Integer, ForeignKey('tracks.id'), primary_key=True)
|
||||||
|
row = Column(Integer, nullable=False)
|
||||||
|
tracks = relationship("Tracks", back_populates="playlists")
|
||||||
|
playlists = relationship("Playlists", back_populates="tracks")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def add_track(session, playlist_id, track_id, row):
|
||||||
|
DEBUG(
|
||||||
|
f"PlaylistTracks.add_track(playlist_id={playlist_id}, "
|
||||||
|
f"track_id={track_id}, row={row})"
|
||||||
|
)
|
||||||
|
plt = PlaylistTracks()
|
||||||
|
plt.playlist_id = playlist_id,
|
||||||
|
plt.track_id = track_id,
|
||||||
|
plt.row = row
|
||||||
|
session.add(plt)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_track_playlists(session, track_id):
|
||||||
|
"Return all PlaylistTracks objects with this track_id"
|
||||||
|
|
||||||
|
return session.query(PlaylistTracks).filter(
|
||||||
|
PlaylistTracks.track_id == track_id).all()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def move_track(session, from_playlist_id, row, to_playlist_id):
|
||||||
|
DEBUG(
|
||||||
|
"PlaylistTracks.move_tracks(from_playlist_id="
|
||||||
|
f"{from_playlist_id}, row={row}, "
|
||||||
|
f"to_playlist_id={to_playlist_id})"
|
||||||
|
)
|
||||||
|
max_row = session.query(func.max(PlaylistTracks.row)).filter(
|
||||||
|
PlaylistTracks.playlist_id == to_playlist_id).scalar()
|
||||||
|
if max_row is None:
|
||||||
|
# Destination playlist is empty; use row 0
|
||||||
|
new_row = 0
|
||||||
|
else:
|
||||||
|
# Destination playlist has tracks; add to end
|
||||||
|
new_row = max_row + 1
|
||||||
|
try:
|
||||||
|
record = session.query(PlaylistTracks).filter(
|
||||||
|
PlaylistTracks.playlist_id == from_playlist_id,
|
||||||
|
PlaylistTracks.row == row).one()
|
||||||
|
except NoResultFound:
|
||||||
|
# Issue #38?
|
||||||
|
ERROR(
|
||||||
|
f"No rows matched in query: "
|
||||||
|
f"PlaylistTracks.playlist_id == {from_playlist_id}, "
|
||||||
|
f"PlaylistTracks.row == {row}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
record.playlist_id = to_playlist_id
|
||||||
|
record.row = new_row
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def new_row(session, playlist_id):
|
||||||
|
"Return row number > largest existing row number"
|
||||||
|
|
||||||
|
last_row = session.query(func.max(PlaylistTracks.row)).one()[0]
|
||||||
|
return last_row + 1
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def remove_all_tracks(session, playlist_id):
|
||||||
|
"""
|
||||||
|
Remove all tracks from passed playlist_id
|
||||||
|
"""
|
||||||
|
|
||||||
|
session.query(PlaylistTracks).filter(
|
||||||
|
PlaylistTracks.playlist_id == playlist_id,
|
||||||
|
).delete()
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def remove_track(session, playlist_id, row):
|
||||||
|
DEBUG(
|
||||||
|
f"PlaylistTracks.remove_track(playlist_id={playlist_id}, "
|
||||||
|
f"row={row})"
|
||||||
|
)
|
||||||
|
session.query(PlaylistTracks).filter(
|
||||||
|
PlaylistTracks.playlist_id == playlist_id,
|
||||||
|
PlaylistTracks.row == row
|
||||||
|
).delete()
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def update_row_track(session, playlist_id, row, track_id):
|
||||||
|
DEBUG(
|
||||||
|
f"PlaylistTracks.update_track_row(playlist_id={playlist_id}, "
|
||||||
|
f"row={row}, track_id={track_id})"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
plt = session.query(PlaylistTracks).filter(
|
||||||
|
PlaylistTracks.playlist_id == playlist_id,
|
||||||
|
PlaylistTracks.row == row
|
||||||
|
).one()
|
||||||
|
except MultipleResultsFound:
|
||||||
|
ERROR(
|
||||||
|
f"Multiple rows matched in query: "
|
||||||
|
f"PlaylistTracks.playlist_id == {playlist_id}, "
|
||||||
|
f"PlaylistTracks.row == {row}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
except NoResultFound:
|
||||||
|
ERROR(
|
||||||
|
f"No rows matched in query: "
|
||||||
|
f"PlaylistTracks.playlist_id == {playlist_id}, "
|
||||||
|
f"PlaylistTracks.row == {row}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
plt.track_id = track_id
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(Base):
|
||||||
|
__tablename__ = 'settings'
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
name = Column(String(32), nullable=False, unique=True)
|
||||||
|
f_datetime = Column(DateTime, default=None, nullable=True)
|
||||||
|
f_int = Column(Integer, default=None, nullable=True)
|
||||||
|
f_string = Column(String(128), default=None, nullable=True)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_int(cls, session, name):
|
||||||
|
try:
|
||||||
|
int_setting = session.query(cls).filter(
|
||||||
|
cls.name == name).one()
|
||||||
|
except NoResultFound:
|
||||||
|
int_setting = Settings()
|
||||||
|
int_setting.name = name
|
||||||
|
int_setting.f_int = None
|
||||||
|
session.add(int_setting)
|
||||||
|
session.commit()
|
||||||
|
return int_setting
|
||||||
|
|
||||||
|
def update(self, session, data):
|
||||||
|
for key, value in data.items():
|
||||||
|
assert hasattr(self, key)
|
||||||
|
setattr(self, key, value)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
class Tracks(Base):
|
||||||
|
__tablename__ = 'tracks'
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
title = Column(String(256), index=True)
|
||||||
|
artist = Column(String(256), index=True)
|
||||||
|
duration = Column(Integer, index=True)
|
||||||
|
start_gap = Column(Integer, index=False)
|
||||||
|
fade_at = Column(Integer, index=False)
|
||||||
|
silence_at = Column(Integer, index=False)
|
||||||
|
path = Column(String(2048), index=False, nullable=False)
|
||||||
|
mtime = Column(Float, index=True)
|
||||||
|
lastplayed = Column(DateTime, index=True, default=None)
|
||||||
|
playlists = relationship("PlaylistTracks", back_populates="tracks")
|
||||||
|
playdates = relationship("Playdates", back_populates="tracks")
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return (
|
||||||
|
f"<Track(id={self.id}, title={self.title}, "
|
||||||
|
f"artist={self.artist}, path={self.path}>"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Not currently used 1 June 2021
|
||||||
|
# @staticmethod
|
||||||
|
# def get_note(session, id):
|
||||||
|
# return session.query(Notes).filter(Notes.id == id).one()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_all_paths(session):
|
||||||
|
"Return a list of paths of all tracks"
|
||||||
|
|
||||||
|
return [a[0] for a in session.query(Tracks.path).all()]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_all_tracks(session):
|
||||||
|
"Return a list of all tracks"
|
||||||
|
|
||||||
|
return session.query(Tracks).all()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_or_create(cls, session, path):
|
||||||
|
DEBUG(f"Tracks.get_or_create(path={path})")
|
||||||
|
try:
|
||||||
|
track = session.query(cls).filter(cls.path == path).one()
|
||||||
|
except NoResultFound:
|
||||||
|
track = Tracks()
|
||||||
|
track.path = path
|
||||||
|
session.add(track)
|
||||||
|
return track
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_duration(session, id):
|
||||||
|
try:
|
||||||
|
return session.query(
|
||||||
|
Tracks.duration).filter(Tracks.id == id).one()[0]
|
||||||
|
except NoResultFound:
|
||||||
|
ERROR(f"Can't find track id {id}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_track_from_filename(session, filename):
|
||||||
|
"""
|
||||||
|
Return track if one and only one track in database has passed
|
||||||
|
filename (ie, basename of path). Return None if zero or more
|
||||||
|
than one track matches.
|
||||||
|
"""
|
||||||
|
|
||||||
|
DEBUG(f"Tracks.get_track_from_filename({filename=})")
|
||||||
|
try:
|
||||||
|
track = session.query(Tracks).filter(Tracks.path.ilike(
|
||||||
|
f'%{os.path.sep}{filename}')).one()
|
||||||
|
return track
|
||||||
|
except (NoResultFound, MultipleResultsFound):
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_track_from_path(session, path):
|
||||||
|
"""
|
||||||
|
Return track with passee path, or None.
|
||||||
|
"""
|
||||||
|
|
||||||
|
DEBUG(f"Tracks.get_track_from_path({path=})")
|
||||||
|
|
||||||
|
return session.query(Tracks).filter(Tracks.path == path).first()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_path(session, track_id):
|
||||||
|
"Return path of passed track_id, or None"
|
||||||
|
|
||||||
|
try:
|
||||||
|
return session.query(Tracks.path).filter(
|
||||||
|
Tracks.id == track_id).one()[0]
|
||||||
|
except NoResultFound:
|
||||||
|
ERROR(f"Can't find track id {track_id}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_track(session, track_id):
|
||||||
|
"Return track or None"
|
||||||
|
|
||||||
|
try:
|
||||||
|
DEBUG(f"Tracks.get_track(track_id={track_id})")
|
||||||
|
track = session.query(Tracks).filter(Tracks.id == track_id).one()
|
||||||
|
return track
|
||||||
|
except NoResultFound:
|
||||||
|
ERROR(f"get_track({track_id}): not found")
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def remove_path(session, path):
|
||||||
|
"Remove track with passed path from database"
|
||||||
|
|
||||||
|
DEBUG(f"Tracks.remove_path({path=})")
|
||||||
|
|
||||||
|
try:
|
||||||
|
session.query(Tracks).filter(Tracks.path == path).delete()
|
||||||
|
session.commit()
|
||||||
|
except IntegrityError as exception:
|
||||||
|
ERROR(f"Can't remove track with {path=} ({exception=})")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def search(session, title=None, artist=None, duration=None):
|
||||||
|
"""
|
||||||
|
Return any tracks matching passed criteria
|
||||||
|
"""
|
||||||
|
|
||||||
|
DEBUG(
|
||||||
|
f"Tracks.search({title=}, {artist=}), {duration=})"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not title and not artist and not duration:
|
||||||
|
return None
|
||||||
|
|
||||||
|
q = session.query(Tracks).filter(False)
|
||||||
|
if title:
|
||||||
|
q = q.filter(Tracks.title == title)
|
||||||
|
if artist:
|
||||||
|
q = q.filter(Tracks.artist == artist)
|
||||||
|
if duration:
|
||||||
|
q = q.filter(Tracks.duration == duration)
|
||||||
|
|
||||||
|
return q.all()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def search_artists(session, text):
|
||||||
|
return (
|
||||||
|
session.query(Tracks)
|
||||||
|
.filter(Tracks.artist.ilike(f"%{text}%"))
|
||||||
|
.order_by(Tracks.title)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def search_titles(session, text):
|
||||||
|
return (
|
||||||
|
session.query(Tracks)
|
||||||
|
.filter(Tracks.title.ilike(f"%{text}%"))
|
||||||
|
.order_by(Tracks.title)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def track_from_id(session, id):
|
||||||
|
return session.query(Tracks).filter(
|
||||||
|
Tracks.id == id).one()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def update_lastplayed(session, track_id):
|
||||||
|
track = session.query(Tracks).filter(Tracks.id == track_id).one()
|
||||||
|
track.lastplayed = datetime.now()
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def update_artist(session, track_id, artist):
|
||||||
|
track = session.query(Tracks).filter(Tracks.id == track_id).one()
|
||||||
|
track.artist = artist
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def update_title(session, track_id, title):
|
||||||
|
track = session.query(Tracks).filter(Tracks.id == track_id).one()
|
||||||
|
track.title = title
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
def update_path(self, newpath):
|
||||||
|
self.path = newpath
|
||||||
642
app/models.py
642
app/models.py
@ -1,642 +0,0 @@
|
|||||||
#!/usr/bin/python3
|
|
||||||
|
|
||||||
import os.path
|
|
||||||
import re
|
|
||||||
|
|
||||||
import sqlalchemy
|
|
||||||
|
|
||||||
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,
|
|
||||||
Integer,
|
|
||||||
String,
|
|
||||||
func
|
|
||||||
)
|
|
||||||
from sqlalchemy.exc import IntegrityError
|
|
||||||
from sqlalchemy.orm import (
|
|
||||||
backref,
|
|
||||||
relationship,
|
|
||||||
sessionmaker,
|
|
||||||
scoped_session, 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
|
|
||||||
|
|
||||||
# Create session at the global level as per
|
|
||||||
# https://docs.sqlalchemy.org/en/13/orm/session_basics.html
|
|
||||||
# and make objects persistent
|
|
||||||
# https://docs.sqlalchemy.org/en/14/orm/session_state_management.html
|
|
||||||
|
|
||||||
engine = sqlalchemy.create_engine(
|
|
||||||
f"{Config.MYSQL_CONNECT}?charset=utf8",
|
|
||||||
encoding='utf-8',
|
|
||||||
echo=Config.DISPLAY_SQL,
|
|
||||||
pool_pre_ping=True)
|
|
||||||
|
|
||||||
# Create a Session factory
|
|
||||||
Session = scoped_session(sessionmaker(bind=engine))
|
|
||||||
# sm: sessionmaker = sessionmaker(bind=engine) # , expire_on_commit=False)
|
|
||||||
# Session = scoped_session(sm)
|
|
||||||
|
|
||||||
Base: DeclarativeMeta = declarative_base()
|
|
||||||
Base.metadata.create_all(engine)
|
|
||||||
|
|
||||||
|
|
||||||
def db_init():
|
|
||||||
return
|
|
||||||
|
|
||||||
|
|
||||||
# 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.commit()
|
|
||||||
|
|
||||||
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.commit()
|
|
||||||
|
|
||||||
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.commit()
|
|
||||||
|
|
||||||
@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.commit()
|
|
||||||
|
|
||||||
|
|
||||||
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: "Tracks") -> None:
|
|
||||||
"""Record that track was played"""
|
|
||||||
|
|
||||||
DEBUG(f"add_playdate(track={track})")
|
|
||||||
|
|
||||||
self.lastplayed = datetime.now()
|
|
||||||
self.track_id = track.id
|
|
||||||
track.update_lastplayed(session)
|
|
||||||
session.add(self)
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
@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.commit()
|
|
||||||
|
|
||||||
|
|
||||||
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.commit()
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
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.commit()
|
|
||||||
|
|
||||||
@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()
|
|
||||||
if self not in session:
|
|
||||||
session.add(self)
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
def remove_all_tracks(self, session: Session) -> None:
|
|
||||||
"""
|
|
||||||
Remove all tracks from this playlist
|
|
||||||
"""
|
|
||||||
|
|
||||||
session.query(PlaylistTracks).filter(
|
|
||||||
PlaylistTracks.playlist_id == self.id,
|
|
||||||
).delete()
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
def remove_track(self, session: Session, row: int) -> None:
|
|
||||||
DEBUG(f"Playlist.remove_track({self.id=}, {row=})")
|
|
||||||
|
|
||||||
session.query(PlaylistTracks).filter(
|
|
||||||
PlaylistTracks.playlist_id == self.id,
|
|
||||||
PlaylistTracks.row == row
|
|
||||||
).delete()
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
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"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
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.commit()
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def move_track(
|
|
||||||
session: Session, from_playlist_id: int, row: int,
|
|
||||||
to_playlist_id: int) -> None:
|
|
||||||
"""
|
|
||||||
Move track between playlists. This would be more efficient with
|
|
||||||
an ORM-enabled UPDATE statement, but this works just fine.
|
|
||||||
"""
|
|
||||||
DEBUG(
|
|
||||||
"PlaylistTracks.move_tracks("
|
|
||||||
f"{from_playlist_id=}, {row=}, {to_playlist_id=})"
|
|
||||||
)
|
|
||||||
|
|
||||||
new_row: int
|
|
||||||
max_row: Optional[int] = 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: PlaylistTracks = session.query(PlaylistTracks).filter(
|
|
||||||
PlaylistTracks.playlist_id == from_playlist_id,
|
|
||||||
PlaylistTracks.row == row).one()
|
|
||||||
except NoResultFound:
|
|
||||||
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 next_free_row(session: Session, playlist: Playlists) -> 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
|
|
||||||
|
|
||||||
|
|
||||||
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.commit()
|
|
||||||
return int_setting
|
|
||||||
|
|
||||||
def update(self, session: Session, data):
|
|
||||||
for key, value in data.items():
|
|
||||||
assert hasattr(self, key)
|
|
||||||
setattr(self, key, value)
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
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) -> None:
|
|
||||||
self.path = path
|
|
||||||
session.add(self)
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
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_from_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_from_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.commit()
|
|
||||||
|
|
||||||
@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.commit()
|
|
||||||
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()
|
|
||||||
|
|
||||||
def update_lastplayed(self, session: Session) -> None:
|
|
||||||
self.lastplayed = datetime.now()
|
|
||||||
session.add(self)
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
def update_artist(self, session: Session, artist: str) -> None:
|
|
||||||
self.artist = artist
|
|
||||||
session.add(self)
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
def update_title(self, session: Session, title: str) -> None:
|
|
||||||
self.title = title
|
|
||||||
session.add(self)
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
def update_path(self, newpath: str) -> None:
|
|
||||||
self.path = newpath
|
|
||||||
14
app/music.py
14
app/music.py
@ -81,16 +81,18 @@ class Music:
|
|||||||
sleep(sleep_time)
|
sleep(sleep_time)
|
||||||
|
|
||||||
with lock:
|
with lock:
|
||||||
DEBUG(f"music._fade(), stopping {p=}", True)
|
DEBUG(f"music._facde(), 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:
|
||||||
@ -99,7 +101,7 @@ 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)
|
||||||
@ -145,13 +147,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:
|
||||||
@ -161,7 +163,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
1661
app/playlists.py
1661
app/playlists.py
File diff suppressed because it is too large
Load Diff
@ -6,24 +6,27 @@ import shutil
|
|||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
from config import Config
|
from config import Config
|
||||||
|
from helpers import show_warning
|
||||||
from log import DEBUG, INFO
|
from log import DEBUG, INFO
|
||||||
from models import Notes, Playdates, Session, Tracks
|
from model import Notes, Playdates, PlaylistTracks, 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 effects
|
from pydub import AudioSegment, 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")
|
||||||
@ -62,9 +65,9 @@ def create_track_from_file(session, path, interactive=False):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
if interactive:
|
if interactive:
|
||||||
msg = f"Importing {path}"
|
str = f"Importing {path}"
|
||||||
INFO(msg)
|
INFO(str)
|
||||||
INFO("-" * len(msg))
|
INFO("-" * len(str))
|
||||||
INFO("Get track info...")
|
INFO("Get track info...")
|
||||||
t = get_music_info(path)
|
t = get_music_info(path)
|
||||||
title = t['title']
|
title = t['title']
|
||||||
@ -116,7 +119,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: {repr(err)}")
|
DEBUG(f"songdb.create_track_from_file({path}): err1: {str(err)}")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Overwrite original file with normalised output
|
# Overwrite original file with normalised output
|
||||||
@ -139,7 +142,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: {repr(err)}")
|
DEBUG(f"songdb.create_track_from_file({path}): err2: {str(err)}")
|
||||||
# Restore original file
|
# Restore original file
|
||||||
shutil.copyfile(path, temp_path)
|
shutil.copyfile(path, temp_path)
|
||||||
finally:
|
finally:
|
||||||
@ -150,7 +153,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}")
|
||||||
@ -220,6 +223,86 @@ 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
|
||||||
@ -246,7 +329,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_from_filename(session, os.path.basename(path))
|
track = Tracks.get_track_from_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:
|
||||||
@ -262,7 +345,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_from_path(session, path)
|
track = Tracks.get_track_from_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
|
||||||
@ -273,15 +356,14 @@ 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 playlist in [a.playlist for a in track.playlists]:
|
for pt in PlaylistTracks.get_track_playlists(session, track.id):
|
||||||
# Create note
|
# Create note
|
||||||
Notes(session, playlist.id, pt.row, note_txt)
|
Notes.add_note(session, pt.playlist_id, pt.row, note_txt)
|
||||||
# TODO: this needs to call playlist.add_note() now
|
|
||||||
# Remove playlist entry
|
# Remove playlist entry
|
||||||
playlist.remove_track(session, pt.row)
|
PlaylistTracks.remove_track(session, pt.playlist_id, pt.row)
|
||||||
|
|
||||||
# Remove Track entry pointing to invalid path
|
# Remove Track entry pointing to invalid path
|
||||||
Tracks.remove_by_path(session, path)
|
Tracks.remove_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)
|
||||||
@ -290,5 +372,44 @@ 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()
|
||||||
@ -175,7 +175,7 @@ border: 1px solid rgb(85, 87, 83);</string>
|
|||||||
</widget>
|
</widget>
|
||||||
</item>
|
</item>
|
||||||
<item>
|
<item>
|
||||||
<widget class="QLabel" name="hdrNextTrack">
|
<widget class="ElideLabel" name="hdrNextTrack">
|
||||||
<property name="minimumSize">
|
<property name="minimumSize">
|
||||||
<size>
|
<size>
|
||||||
<width>0</width>
|
<width>0</width>
|
||||||
@ -794,9 +794,19 @@ border: 1px solid rgb(85, 87, 83);</string>
|
|||||||
<addaction name="separator"/>
|
<addaction name="separator"/>
|
||||||
<addaction name="actionSetNext"/>
|
<addaction name="actionSetNext"/>
|
||||||
</widget>
|
</widget>
|
||||||
|
<widget class="QMenu" name="menuTest">
|
||||||
|
<property name="title">
|
||||||
|
<string>TestMo&de</string>
|
||||||
|
</property>
|
||||||
|
<addaction name="actionTestFunction"/>
|
||||||
|
<addaction name="separator"/>
|
||||||
|
<addaction name="actionSkipToFade"/>
|
||||||
|
<addaction name="actionSkipToEnd"/>
|
||||||
|
</widget>
|
||||||
<addaction name="menuFile"/>
|
<addaction name="menuFile"/>
|
||||||
<addaction name="menuPlaylist"/>
|
<addaction name="menuPlaylist"/>
|
||||||
<addaction name="menu_Tracks"/>
|
<addaction name="menu_Tracks"/>
|
||||||
|
<addaction name="menuTest"/>
|
||||||
</widget>
|
</widget>
|
||||||
<widget class="QStatusBar" name="statusbar">
|
<widget class="QStatusBar" name="statusbar">
|
||||||
<property name="enabled">
|
<property name="enabled">
|
||||||
@ -1004,6 +1014,13 @@ border: 1px solid rgb(85, 87, 83);</string>
|
|||||||
</property>
|
</property>
|
||||||
</action>
|
</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>
|
||||||
|
|||||||
@ -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.6
|
# Created by: PyQt5 UI code generator 5.15.4
|
||||||
#
|
#
|
||||||
# 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.
|
||||||
@ -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 = QtWidgets.QLabel(self.centralwidget)
|
self.hdrNextTrack = ElideLabel(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()
|
||||||
@ -356,6 +356,8 @@ class Ui_MainWindow(object):
|
|||||||
self.menuPlaylist.setObjectName("menuPlaylist")
|
self.menuPlaylist.setObjectName("menuPlaylist")
|
||||||
self.menu_Tracks = QtWidgets.QMenu(self.menubar)
|
self.menu_Tracks = QtWidgets.QMenu(self.menubar)
|
||||||
self.menu_Tracks.setObjectName("menu_Tracks")
|
self.menu_Tracks.setObjectName("menu_Tracks")
|
||||||
|
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)
|
||||||
@ -465,13 +467,18 @@ class Ui_MainWindow(object):
|
|||||||
self.menu_Tracks.addAction(self.action_Resume_previous)
|
self.menu_Tracks.addAction(self.action_Resume_previous)
|
||||||
self.menu_Tracks.addSeparator()
|
self.menu_Tracks.addSeparator()
|
||||||
self.menu_Tracks.addAction(self.actionSetNext)
|
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_Tracks.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) # type: ignore
|
self.actionE_xit.triggered.connect(MainWindow.close)
|
||||||
QtCore.QMetaObject.connectSlotsByName(MainWindow)
|
QtCore.QMetaObject.connectSlotsByName(MainWindow)
|
||||||
|
|
||||||
def retranslateUi(self, MainWindow):
|
def retranslateUi(self, MainWindow):
|
||||||
@ -506,6 +513,7 @@ class Ui_MainWindow(object):
|
|||||||
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_Tracks.setTitle(_translate("MainWindow", "&Tracks"))
|
||||||
|
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"))
|
||||||
@ -542,4 +550,5 @@ 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
|
||||||
import icons_rc
|
import icons_rc
|
||||||
|
|||||||
@ -1,17 +0,0 @@
|
|||||||
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)
|
|
||||||
56
conftest.py
56
conftest.py
@ -1,56 +0,0 @@
|
|||||||
# https://itnext.io/setting-up-transactional-tests-with-pytest-and-sqlalchemy-b2d726347629
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
import sys
|
|
||||||
|
|
||||||
from sqlalchemy import create_engine
|
|
||||||
from sqlalchemy.orm import scoped_session, sessionmaker
|
|
||||||
|
|
||||||
sys.path.append("app")
|
|
||||||
from app.models import Base # noqa E402
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def connection():
|
|
||||||
engine = create_engine(
|
|
||||||
"mysql+mysqldb://musicmuster_testing:musicmuster_testing@"
|
|
||||||
"localhost/musicmuster_testing"
|
|
||||||
)
|
|
||||||
return engine.connect()
|
|
||||||
|
|
||||||
|
|
||||||
def seed_database():
|
|
||||||
pass
|
|
||||||
|
|
||||||
# users = [
|
|
||||||
# {
|
|
||||||
# "id": 1,
|
|
||||||
# "name": "John Doe",
|
|
||||||
# },
|
|
||||||
# # ...
|
|
||||||
# ]
|
|
||||||
|
|
||||||
# for user in users:
|
|
||||||
# db_user = User(**user)
|
|
||||||
# db_session.add(db_user)
|
|
||||||
# db_session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def setup_database(connection):
|
|
||||||
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()
|
|
||||||
@ -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.models import Base
|
from app.model 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:
|
||||||
|
|||||||
@ -1,30 +0,0 @@
|
|||||||
"""Update notecolours table
|
|
||||||
|
|
||||||
Revision ID: 52cbded98e7c
|
|
||||||
Revises: c55992d1fe5f
|
|
||||||
Create Date: 2022-02-06 12:34:30.099417
|
|
||||||
|
|
||||||
"""
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from sqlalchemy.dialects import mysql
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision = '52cbded98e7c'
|
|
||||||
down_revision = 'c55992d1fe5f'
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade():
|
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
|
||||||
op.add_column('notecolours', sa.Column('colour', sa.String(length=21), nullable=True))
|
|
||||||
op.drop_column('notecolours', 'hexcolour')
|
|
||||||
# ### end Alembic commands ###
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade():
|
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
|
||||||
op.add_column('notecolours', sa.Column('hexcolour', mysql.VARCHAR(length=6), nullable=True))
|
|
||||||
op.drop_column('notecolours', 'colour')
|
|
||||||
# ### end Alembic commands ###
|
|
||||||
219
poetry.lock
generated
219
poetry.lock
generated
@ -1,6 +1,6 @@
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "alembic"
|
name = "alembic"
|
||||||
version = "1.7.6"
|
version = "1.7.5"
|
||||||
description = "A database migration tool for SQLAlchemy."
|
description = "A database migration tool for SQLAlchemy."
|
||||||
category = "main"
|
category = "main"
|
||||||
optional = false
|
optional = false
|
||||||
@ -158,11 +158,11 @@ lingua = ["lingua"]
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "markupsafe"
|
name = "markupsafe"
|
||||||
version = "2.1.0"
|
version = "2.0.1"
|
||||||
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.7"
|
python-versions = ">=3.6"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "matplotlib-inline"
|
name = "matplotlib-inline"
|
||||||
@ -187,7 +187,7 @@ python-versions = ">=3.5, <4"
|
|||||||
name = "mypy"
|
name = "mypy"
|
||||||
version = "0.931"
|
version = "0.931"
|
||||||
description = "Optional static typing for Python"
|
description = "Optional static typing for Python"
|
||||||
category = "main"
|
category = "dev"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.6"
|
python-versions = ">=3.6"
|
||||||
|
|
||||||
@ -204,7 +204,7 @@ python2 = ["typed-ast (>=1.4.0,<2)"]
|
|||||||
name = "mypy-extensions"
|
name = "mypy-extensions"
|
||||||
version = "0.4.3"
|
version = "0.4.3"
|
||||||
description = "Experimental type system extensions for programs checked with the mypy typechecker."
|
description = "Experimental type system extensions for programs checked with the mypy typechecker."
|
||||||
category = "main"
|
category = "dev"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = "*"
|
python-versions = "*"
|
||||||
|
|
||||||
@ -326,7 +326,7 @@ python-versions = "*"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pyqt5-sip"
|
name = "pyqt5-sip"
|
||||||
version = "12.9.1"
|
version = "12.9.0"
|
||||||
description = "The sip module support for PyQt5"
|
description = "The sip module support for PyQt5"
|
||||||
category = "main"
|
category = "main"
|
||||||
optional = false
|
optional = false
|
||||||
@ -353,42 +353,6 @@ category = "main"
|
|||||||
optional = false
|
optional = false
|
||||||
python-versions = "*"
|
python-versions = "*"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pytest"
|
|
||||||
version = "7.0.1"
|
|
||||||
description = "pytest: simple powerful testing with Python"
|
|
||||||
category = "dev"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.6"
|
|
||||||
|
|
||||||
[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.12118"
|
||||||
@ -476,7 +440,7 @@ python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
|
|||||||
name = "tomli"
|
name = "tomli"
|
||||||
version = "2.0.1"
|
version = "2.0.1"
|
||||||
description = "A lil' TOML parser"
|
description = "A lil' TOML parser"
|
||||||
category = "main"
|
category = "dev"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.7"
|
python-versions = ">=3.7"
|
||||||
|
|
||||||
@ -495,7 +459,7 @@ test = ["pytest"]
|
|||||||
name = "typing-extensions"
|
name = "typing-extensions"
|
||||||
version = "4.0.1"
|
version = "4.0.1"
|
||||||
description = "Backported and Experimental Type Hints for Python 3.6+"
|
description = "Backported and Experimental Type Hints for Python 3.6+"
|
||||||
category = "main"
|
category = "dev"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.6"
|
python-versions = ">=3.6"
|
||||||
|
|
||||||
@ -514,8 +478,8 @@ content-hash = "0c1303cb7e23bd0c24c31b08e727cfe278bc6bdaa2ac3450a8c689c2ee7b74f2
|
|||||||
|
|
||||||
[metadata.files]
|
[metadata.files]
|
||||||
alembic = [
|
alembic = [
|
||||||
{file = "alembic-1.7.6-py3-none-any.whl", hash = "sha256:ad842f2c3ab5c5d4861232730779c05e33db4ba880a08b85eb505e87c01095bc"},
|
{file = "alembic-1.7.5-py3-none-any.whl", hash = "sha256:a9dde941534e3d7573d9644e8ea62a2953541e27bc1793e166f60b777ae098b4"},
|
||||||
{file = "alembic-1.7.6.tar.gz", hash = "sha256:6c0c05e9768a896d804387e20b299880fe01bc56484246b0dffe8075d6d3d847"},
|
{file = "alembic-1.7.5.tar.gz", hash = "sha256:7c328694a2e68f03ee971e63c3bd885846470373a5b532cf2c9f1601c413b153"},
|
||||||
]
|
]
|
||||||
appnope = [
|
appnope = [
|
||||||
{file = "appnope-0.1.2-py2.py3-none-any.whl", hash = "sha256:93aa393e9d6c54c5cd570ccadd8edad61ea0c4b9ea7a01409020c9aa019eb442"},
|
{file = "appnope-0.1.2-py2.py3-none-any.whl", hash = "sha256:93aa393e9d6c54c5cd570ccadd8edad61ea0c4b9ea7a01409020c9aa019eb442"},
|
||||||
@ -614,46 +578,75 @@ mako = [
|
|||||||
{file = "Mako-1.1.6.tar.gz", hash = "sha256:4e9e345a41924a954251b95b4b28e14a301145b544901332e658907a7464b6b2"},
|
{file = "Mako-1.1.6.tar.gz", hash = "sha256:4e9e345a41924a954251b95b4b28e14a301145b544901332e658907a7464b6b2"},
|
||||||
]
|
]
|
||||||
markupsafe = [
|
markupsafe = [
|
||||||
{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_universal2.whl", hash = "sha256:d8446c54dc28c01e5a2dbac5a25f071f6653e6e40f3a8818e8b45d790fe6ef53"},
|
||||||
{file = "MarkupSafe-2.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:290b02bab3c9e216da57c1d11d2ba73a9f73a614bbdcc027d299a60cdfabb11a"},
|
{file = "MarkupSafe-2.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:36bc903cbb393720fad60fc28c10de6acf10dc6cc883f3e24ee4012371399a38"},
|
||||||
{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_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2d7d807855b419fc2ed3e631034685db6079889a1f01d5d9dac950f764da3dad"},
|
||||||
{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_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:add36cb2dbb8b736611303cd3bfcee00afd96471b09cda130da3581cbdc56a6d"},
|
||||||
{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-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-musllinux_1_1_aarch64.whl", hash = "sha256:d3b64c65328cb4cd252c94f83e66e3d7acf8891e60ebf588d7b493a55a1dbf26"},
|
{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_i686.whl", hash = "sha256:96de1932237abe0a13ba68b63e94113678c379dca45afa040a17b6e1ad7ed076"},
|
{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_x86_64.whl", hash = "sha256:75bb36f134883fdbe13d8e63b8675f5f12b80bb6627f7714c7d6c5becf22719f"},
|
{file = "MarkupSafe-2.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:cdfba22ea2f0029c9261a4bd07e830a8da012291fbe44dc794e488b6c9bb353a"},
|
||||||
{file = "MarkupSafe-2.1.0-cp310-cp310-win32.whl", hash = "sha256:4056f752015dfa9828dce3140dbadd543b555afb3252507348c493def166d454"},
|
{file = "MarkupSafe-2.0.1-cp310-cp310-win32.whl", hash = "sha256:99df47edb6bda1249d3e80fdabb1dab8c08ef3975f69aed437cb69d0a5de1e28"},
|
||||||
{file = "MarkupSafe-2.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:d4e702eea4a2903441f2735799d217f4ac1b55f7d8ad96ab7d4e25417cb0827c"},
|
{file = "MarkupSafe-2.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:e0f138900af21926a02425cf736db95be9f4af72ba1bb21453432a07f6082134"},
|
||||||
{file = "MarkupSafe-2.1.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:f0eddfcabd6936558ec020130f932d479930581171368fd728efcfb6ef0dd357"},
|
{file = "MarkupSafe-2.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f9081981fe268bd86831e5c75f7de206ef275defcb82bc70740ae6dc507aee51"},
|
||||||
{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_i686.whl", hash = "sha256:0955295dd5eec6cb6cc2fe1698f4c6d84af2e92de33fbcac4111913cd100a6ff"},
|
||||||
{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-manylinux1_x86_64.whl", hash = "sha256:0446679737af14f45767963a1a9ef7620189912317d095f2d9ffa183a4d25d2b"},
|
||||||
{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_i686.whl", hash = "sha256:f826e31d18b516f653fe296d967d700fddad5901ae07c622bb3705955e1faa94"},
|
||||||
{file = "MarkupSafe-2.1.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:736895a020e31b428b3382a7887bfea96102c529530299f426bf2e636aacec9e"},
|
{file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:fa130dd50c57d53368c9d59395cb5526eda596d3ffe36666cd81a44d56e48872"},
|
||||||
{file = "MarkupSafe-2.1.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:679cbb78914ab212c49c67ba2c7396dc599a8479de51b9a87b174700abd9ea49"},
|
{file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:905fec760bd2fa1388bb5b489ee8ee5f7291d692638ea5f67982d968366bef9f"},
|
||||||
{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_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf5d821ffabf0ef3533c39c518f3357b171a1651c1ff6827325e4489b0e46c3c"},
|
||||||
{file = "MarkupSafe-2.1.0-cp37-cp37m-win32.whl", hash = "sha256:8da5924cb1f9064589767b0f3fc39d03e3d0fb5aa29e0cb21d43106519bd624a"},
|
{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-win_amd64.whl", hash = "sha256:454ffc1cbb75227d15667c09f164a0099159da0c1f3d2636aa648f12675491ad"},
|
{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-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:142119fb14a1ef6d758912b25c4e803c3ff66920635c44078666fe7cc3f8f759"},
|
{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_x86_64.whl", hash = "sha256:b2a5a856019d2833c56a3dcac1b80fe795c95f401818ea963594b345929dffa7"},
|
{file = "MarkupSafe-2.0.1-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:63f3268ba69ace99cab4e3e3b5840b03340efed0948ab8f78d2fd87ee5442a4f"},
|
||||||
{file = "MarkupSafe-2.1.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d1fb9b2eec3c9714dd936860850300b51dbaa37404209c8d4cb66547884b7ed"},
|
{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_x86_64.manylinux2014_x86_64.whl", hash = "sha256:62c0285e91414f5c8f621a17b69fc0088394ccdaa961ef469e833dbff64bd5ea"},
|
{file = "MarkupSafe-2.0.1-cp36-cp36m-win32.whl", hash = "sha256:6c4ca60fa24e85fe25b912b01e62cb969d69a23a5d5867682dd3e80b5b02581d"},
|
||||||
{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-cp36-cp36m-win_amd64.whl", hash = "sha256:b2f4bf27480f5e5e8ce285a8c8fd176c0b03e93dcc6646477d4630e83440c6a9"},
|
||||||
{file = "MarkupSafe-2.1.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:f02cf7221d5cd915d7fa58ab64f7ee6dd0f6cddbb48683debf5d04ae9b1c2cc1"},
|
{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_i686.whl", hash = "sha256:d5653619b3eb5cbd35bfba3c12d575db2a74d15e0e1c08bf1db788069d410ce8"},
|
{file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:6557b31b5e2c9ddf0de32a691f2312a32f77cd7681d8af66c2692efdbef84c18"},
|
||||||
{file = "MarkupSafe-2.1.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:7d2f5d97fcbd004c03df8d8fe2b973fe2b14e7bfeb2cfa012eaa8759ce9a762f"},
|
{file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:49e3ceeabbfb9d66c3aef5af3a60cc43b85c33df25ce03d0031a608b0a8b2e3f"},
|
||||||
{file = "MarkupSafe-2.1.0-cp38-cp38-win32.whl", hash = "sha256:3cace1837bc84e63b3fd2dfce37f08f8c18aeb81ef5cf6bb9b51f625cb4e6cd8"},
|
{file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:d7f9850398e85aba693bb640262d3611788b1f29a79f0c93c565694658f4071f"},
|
||||||
{file = "MarkupSafe-2.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:fabbe18087c3d33c5824cb145ffca52eccd053061df1d79d4b66dafa5ad2a5ea"},
|
{file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:6a7fae0dd14cf60ad5ff42baa2e95727c3d81ded453457771d02b7d2b3f9c0c2"},
|
||||||
{file = "MarkupSafe-2.1.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:023af8c54fe63530545f70dd2a2a7eed18d07a9a77b94e8bf1e2ff7f252db9a3"},
|
{file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:b7f2d075102dc8c794cbde1947378051c4e5180d52d276987b8d28a3bd58c17d"},
|
||||||
{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_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e9936f0b261d4df76ad22f8fee3ae83b60d7c3e871292cd42f40b81b70afae85"},
|
||||||
{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_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:2a7d351cbd8cfeb19ca00de495e224dea7e7d919659c2841bbb7f420ad03e2d6"},
|
||||||
{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-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_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30c653fde75a6e5eb814d2a0a89378f83d1d3f502ab710904ee585c38888816c"},
|
{file = "MarkupSafe-2.0.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d6c7ebd4e944c85e2c3421e612a7057a2f48d478d79e61800d81468a8d842207"},
|
||||||
{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_i686.whl", hash = "sha256:f0567c4dc99f264f49fe27da5f735f414c4e7e7dd850cfd8e69f0862d7c74ea9"},
|
||||||
{file = "MarkupSafe-2.1.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:598b65d74615c021423bd45c2bc5e9b59539c875a9bdb7e5f2a6b92dfcfc268d"},
|
{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_x86_64.whl", hash = "sha256:599941da468f2cf22bf90a84f6e2a65524e87be2fce844f96f2dd9a6c9d1e635"},
|
{file = "MarkupSafe-2.0.1-cp37-cp37m-win32.whl", hash = "sha256:a30e67a65b53ea0a5e62fe23682cfe22712e01f453b95233b25502f7c61cb415"},
|
||||||
{file = "MarkupSafe-2.1.0-cp39-cp39-win32.whl", hash = "sha256:e6f7f3f41faffaea6596da86ecc2389672fa949bd035251eab26dc6697451d05"},
|
{file = "MarkupSafe-2.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:611d1ad9a4288cf3e3c16014564df047fe08410e628f89805e475368bd304914"},
|
||||||
{file = "MarkupSafe-2.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:b8811d48078d1cf2a6863dafb896e68406c5f513048451cd2ded0473133473c7"},
|
{file = "MarkupSafe-2.0.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:5bb28c636d87e840583ee3adeb78172efc47c8b26127267f54a9c0ec251d41a9"},
|
||||||
{file = "MarkupSafe-2.1.0.tar.gz", hash = "sha256:80beaf63ddfbc64a0452b841d8036ca0611e049650e20afcb882f5d3c266d65f"},
|
{file = "MarkupSafe-2.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:be98f628055368795d818ebf93da628541e10b75b41c559fdf36d104c5787066"},
|
||||||
|
{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"},
|
||||||
|
{file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:01a9b8ea66f1658938f65b93a85ebe8bc016e6769611be228d797c9d998dd298"},
|
||||||
|
{file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:47ab1e7b91c098ab893b828deafa1203de86d0bc6ab587b160f78fe6c4011f75"},
|
||||||
|
{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"},
|
||||||
]
|
]
|
||||||
matplotlib-inline = [
|
matplotlib-inline = [
|
||||||
{file = "matplotlib-inline-0.1.3.tar.gz", hash = "sha256:a04bfba22e0d1395479f866853ec1ee28eea1485c1d69a6faf00dc3e24ff34ee"},
|
{file = "matplotlib-inline-0.1.3.tar.gz", hash = "sha256:a04bfba22e0d1395479f866853ec1ee28eea1485c1d69a6faf00dc3e24ff34ee"},
|
||||||
@ -776,27 +769,27 @@ 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.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:6b2e553e21b7ff124007a6b9168f8bb8c171fdf230d31ca0588df180f10bacbe"},
|
{file = "PyQt5_sip-12.9.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:6d5bca2fc222d58e8093ee8a81a6e3437067bb22bc3f86d06ec8be721e15e90a"},
|
||||||
{file = "PyQt5_sip-12.9.1-cp310-cp310-manylinux1_x86_64.whl", hash = "sha256:5740a1770d6b92a5dca8bb0bda4620baf0d7a726beb864f69c667ddac91d6f64"},
|
{file = "PyQt5_sip-12.9.0-cp310-cp310-manylinux1_x86_64.whl", hash = "sha256:d59af63120d1475b2bf94fe8062610720a9be1e8940ea146c7f42bb449d49067"},
|
||||||
{file = "PyQt5_sip-12.9.1-cp310-cp310-win32.whl", hash = "sha256:9699286fcdf4f75a4b91c7e4832c0f926af18d648c62a4ed72dd294c1a93705a"},
|
{file = "PyQt5_sip-12.9.0-cp310-cp310-win32.whl", hash = "sha256:0fc9aefacf502696710b36cdc9fa2a61487f55ee883dbcf2c2a6477e261546f7"},
|
||||||
{file = "PyQt5_sip-12.9.1-cp310-cp310-win_amd64.whl", hash = "sha256:e2792af660da7479799f53028da88190ae8b4a0ad5acc2acbfd6c7bbfe110d58"},
|
{file = "PyQt5_sip-12.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:485972daff2fb0311013f471998f8ec8262ea381bded244f9d14edaad5f54271"},
|
||||||
{file = "PyQt5_sip-12.9.1-cp36-cp36m-macosx_10_6_intel.whl", hash = "sha256:7ee08ad0ebf85b935f5d8d38306f8665fff9a6026c14fc0a7d780649e889c096"},
|
{file = "PyQt5_sip-12.9.0-cp36-cp36m-macosx_10_6_intel.whl", hash = "sha256:d85002238b5180bce4b245c13d6face848faa1a7a9e5c6e292025004f2fd619a"},
|
||||||
{file = "PyQt5_sip-12.9.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:2d21420b0739df2607864e2c80ca01994bc40cb116da6ad024ea8d9f407b0356"},
|
{file = "PyQt5_sip-12.9.0-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:83c3220b1ca36eb8623ba2eb3766637b19eb0ce9f42336ad8253656d32750c0a"},
|
||||||
{file = "PyQt5_sip-12.9.1-cp36-cp36m-win32.whl", hash = "sha256:ffd25051962c593d1c3c30188b9fbd8589ba17acd23a0202dc987bd3552fa611"},
|
{file = "PyQt5_sip-12.9.0-cp36-cp36m-win32.whl", hash = "sha256:d8b2bdff7bbf45bc975c113a03b14fd669dc0c73e1327f02706666a7dd51a197"},
|
||||||
{file = "PyQt5_sip-12.9.1-cp36-cp36m-win_amd64.whl", hash = "sha256:78ef8f1f41819661aa8e3117d6c1cd76fa14aef265e5bfd515dbfc64d412416b"},
|
{file = "PyQt5_sip-12.9.0-cp36-cp36m-win_amd64.whl", hash = "sha256:69a3ad4259172e2b1aa9060de211efac39ddd734a517b1924d9c6c0cc4f55f96"},
|
||||||
{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-macosx_10_9_x86_64.whl", hash = "sha256:42274a501ab4806d2c31659170db14c282b8313d2255458064666d9e70d96206"},
|
||||||
{file = "PyQt5_sip-12.9.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:c9a977d2835a5fbf250b00d61267dc228bdec9e20c7420d4e8d54d6f20410f87"},
|
{file = "PyQt5_sip-12.9.0-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:6a8701892a01a5a2a4720872361197cc80fdd5f49c8482d488ddf38c9c84f055"},
|
||||||
{file = "PyQt5_sip-12.9.1-cp37-cp37m-win32.whl", hash = "sha256:cec6ebf0b1163b18f09bc523160c467a9528b6dca129753827ac0bc432b332ae"},
|
{file = "PyQt5_sip-12.9.0-cp37-cp37m-win32.whl", hash = "sha256:ac57d796c78117eb39edd1d1d1aea90354651efac9d3590aac67fa4983f99f1f"},
|
||||||
{file = "PyQt5_sip-12.9.1-cp37-cp37m-win_amd64.whl", hash = "sha256:82c1b3080db7634fa318fddbb3cfaa30e63a67bca1001a76958c31f30b774a9d"},
|
{file = "PyQt5_sip-12.9.0-cp37-cp37m-win_amd64.whl", hash = "sha256:4347bd81d30c8e3181e553b3734f91658cfbdd8f1a19f254777f906870974e6d"},
|
||||||
{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-macosx_10_9_x86_64.whl", hash = "sha256:c446971c360a0a1030282a69375a08c78e8a61d568bfd6dab3dcc5cf8817f644"},
|
||||||
{file = "PyQt5_sip-12.9.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:ce7a8b3af9db378c46b345d9809d481a74c4bfcd3129486c054fbdbc6a3503f9"},
|
{file = "PyQt5_sip-12.9.0-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:fc43f2d7c438517ee33e929e8ae77132749c15909afab6aeece5fcf4147ffdb5"},
|
||||||
{file = "PyQt5_sip-12.9.1-cp38-cp38-win32.whl", hash = "sha256:8fe5b3e4bbb8b472d05631cad21028d073f9f8eda770041449514cb3824a867f"},
|
{file = "PyQt5_sip-12.9.0-cp38-cp38-win32.whl", hash = "sha256:055581c6fed44ba4302b70eeb82e979ff70400037358908f251cd85cbb3dbd93"},
|
||||||
{file = "PyQt5_sip-12.9.1-cp38-cp38-win_amd64.whl", hash = "sha256:5d59c4a5e856a35c41b47f5a23e1635b38cd1672f4f0122a68ebcb6889523ff2"},
|
{file = "PyQt5_sip-12.9.0-cp38-cp38-win_amd64.whl", hash = "sha256:c5216403d4d8d857ec4a61f631d3945e44fa248aa2415e9ee9369ab7c8a4d0c7"},
|
||||||
{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-macosx_10_9_x86_64.whl", hash = "sha256:a25b9843c7da6a1608f310879c38e6434331aab1dc2fe6cb65c14f1ecf33780e"},
|
||||||
{file = "PyQt5_sip-12.9.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:53e23dcc0fc3857204abd47660e383b930941bd1aeaf3c78ed59c5c12dd48010"},
|
{file = "PyQt5_sip-12.9.0-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:dd05c768c2b55ffe56a9d49ce6cc77cdf3d53dbfad935258a9e347cbfd9a5850"},
|
||||||
{file = "PyQt5_sip-12.9.1-cp39-cp39-win32.whl", hash = "sha256:ee188eac5fd94dfe8d9e04a9e7fbda65c3535d5709278d8b7367ebd54f00e27f"},
|
{file = "PyQt5_sip-12.9.0-cp39-cp39-win32.whl", hash = "sha256:4f8e05fe01d54275877c59018d8e82dcdd0bc5696053a8b830eecea3ce806121"},
|
||||||
{file = "PyQt5_sip-12.9.1-cp39-cp39-win_amd64.whl", hash = "sha256:989d51c41456cc496cb96f0b341464932b957040d26561f0bb4cf5a0914d6b36"},
|
{file = "PyQt5_sip-12.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:b09f4cd36a4831229fb77c424d89635fa937d97765ec90685e2f257e56a2685a"},
|
||||||
{file = "PyQt5_sip-12.9.1.tar.gz", hash = "sha256:2f24f299b44c511c23796aafbbb581bfdebf78d0905657b7cee2141b4982030e"},
|
{file = "PyQt5_sip-12.9.0.tar.gz", hash = "sha256:d3e4489d7c2b0ece9d203ae66e573939f7f60d4d29e089c9f11daa17cfeaae32"},
|
||||||
]
|
]
|
||||||
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"},
|
||||||
@ -811,14 +804,6 @@ 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.0.1-py3-none-any.whl", hash = "sha256:9ce3ff477af913ecf6321fe337b93a2c0dcf2a0a1439c43f5452112c1e4280db"},
|
|
||||||
{file = "pytest-7.0.1.tar.gz", hash = "sha256:e30905a0c131d3d94b89624a1cc5afec3e0ba2fbdb151867d8e0ebd49850f171"},
|
|
||||||
]
|
|
||||||
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.12118.tar.gz", hash = "sha256:566f2f7c303f6800851cacc016df1c6eeec094ad63e0a49d87db9d698094f1fb"},
|
||||||
{file = "python_vlc-3.0.12118-py3-none-any.whl", hash = "sha256:f88be06c6f819a4db2de1c586b193b5df1410ff10fca33b8c6f4e56037c46f7b"},
|
{file = "python_vlc-3.0.12118-py3-none-any.whl", hash = "sha256:f88be06c6f819a4db2de1c586b193b5df1410ff10fca33b8c6f4e56037c46f7b"},
|
||||||
@ -892,7 +877,3 @@ wcwidth = [
|
|||||||
{file = "wcwidth-0.2.5-py2.py3-none-any.whl", hash = "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784"},
|
{file = "wcwidth-0.2.5-py2.py3-none-any.whl", hash = "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784"},
|
||||||
{file = "wcwidth-0.2.5.tar.gz", hash = "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83"},
|
{file = "wcwidth-0.2.5.tar.gz", hash = "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83"},
|
||||||
]
|
]
|
||||||
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"},
|
|
||||||
]
|
|
||||||
|
|||||||
@ -16,16 +16,11 @@ 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.1"
|
PyQt5-sip = "^12.9.0"
|
||||||
mypy = "^0.931"
|
|
||||||
sqlalchemy-stubs = "^0.4"
|
|
||||||
|
|
||||||
[tool.poetry.dev-dependencies]
|
[tool.poetry.dev-dependencies]
|
||||||
mypy = "^0.931"
|
mypy = "^0.931"
|
||||||
pytest = "^7.0.0"
|
|
||||||
ipdb = "^0.13.9"
|
ipdb = "^0.13.9"
|
||||||
sqlalchemy-stubs = "^0.4"
|
|
||||||
pytest-qt = "^4.0.2"
|
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["poetry-core>=1.0.0"]
|
requires = ["poetry-core>=1.0.0"]
|
||||||
|
|||||||
@ -1,2 +0,0 @@
|
|||||||
[pytest]
|
|
||||||
addopts = -xls
|
|
||||||
@ -1,72 +0,0 @@
|
|||||||
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"
|
|
||||||
514
test_models.py
514
test_models.py
@ -1,514 +0,0 @@
|
|||||||
import os.path
|
|
||||||
import random
|
|
||||||
import string
|
|
||||||
|
|
||||||
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 = "#abcdef"
|
|
||||||
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 = "#abcdef"
|
|
||||||
note2_colour = "#00ff00"
|
|
||||||
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 = "#abcdef"
|
|
||||||
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 = "#abcdef"
|
|
||||||
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)
|
|
||||||
assert playdate
|
|
||||||
|
|
||||||
last_played = Playdates.last_played(session, track.id)
|
|
||||||
assert playdate.lastplayed == last_played
|
|
||||||
|
|
||||||
|
|
||||||
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)
|
|
||||||
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
|
|
||||||
|
|
||||||
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_playlisttracks_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)
|
|
||||||
track1 = Tracks(session, track1_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
|
|
||||||
PlaylistTracks.move_track(
|
|
||||||
session, playlist1.id, track2_row, playlist2.id)
|
|
||||||
|
|
||||||
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"
|
|
||||||
track1 = Tracks(session, track1_path)
|
|
||||||
track2_path = "/m/n/o"
|
|
||||||
track2 = 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_from_filename(session):
|
|
||||||
track1_path = "/a/b/c"
|
|
||||||
|
|
||||||
track1 = Tracks(session, track1_path)
|
|
||||||
assert Tracks.get_from_filename(
|
|
||||||
session, os.path.basename(track1_path)
|
|
||||||
) is track1
|
|
||||||
|
|
||||||
|
|
||||||
def test_tracks_from_path(session):
|
|
||||||
track1_path = "/a/b/c"
|
|
||||||
|
|
||||||
track1 = Tracks(session, track1_path)
|
|
||||||
assert Tracks.get_from_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_from_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)
|
|
||||||
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
|
|
||||||
@ -1,191 +0,0 @@
|
|||||||
from PyQt5.QtCore import Qt
|
|
||||||
|
|
||||||
from app.playlists import Notes, PlaylistTab, Tracks
|
|
||||||
from app.models import Playlists
|
|
||||||
from musicmuster import Window
|
|
||||||
|
|
||||||
|
|
||||||
def test_init(qtbot, session):
|
|
||||||
"""Just check we can create a playlist_tab"""
|
|
||||||
|
|
||||||
playlist = Playlists(session, "my playlist")
|
|
||||||
playlist_tab = PlaylistTab(None, session, playlist)
|
|
||||||
assert playlist_tab
|
|
||||||
|
|
||||||
|
|
||||||
def test_save_and_restore(qtbot, session):
|
|
||||||
"""Playlist with one track, one note, save and restore"""
|
|
||||||
|
|
||||||
# Create playlist
|
|
||||||
playlist = Playlists(session, "my playlist")
|
|
||||||
playlist_tab = PlaylistTab(None, session, playlist)
|
|
||||||
|
|
||||||
# Insert a note
|
|
||||||
note_text = "my note"
|
|
||||||
note_row = 7
|
|
||||||
note = Notes(session, playlist.id, note_row, note_text)
|
|
||||||
playlist_tab._insert_note(session, note)
|
|
||||||
|
|
||||||
# Add a track
|
|
||||||
track_path = "/a/b/c"
|
|
||||||
track = Tracks(session, track_path)
|
|
||||||
playlist_tab.insert_track(session, track)
|
|
||||||
|
|
||||||
# Save playlist
|
|
||||||
playlist_tab.save_playlist(session)
|
|
||||||
|
|
||||||
# Retrieve playlist
|
|
||||||
playlists = Playlists.get_open(session)
|
|
||||||
assert len(playlists) == 1
|
|
||||||
retrieved_playlist = playlists[0]
|
|
||||||
assert track_path in [a.path for a in retrieved_playlist.tracks.values()]
|
|
||||||
assert note_text in [a.note for a in retrieved_playlist.notes]
|
|
||||||
|
|
||||||
|
|
||||||
def test_meta_all_clear(qtbot, session):
|
|
||||||
|
|
||||||
# Create playlist
|
|
||||||
playlist = Playlists(session, "my playlist")
|
|
||||||
playlist_tab = PlaylistTab(None, session, playlist)
|
|
||||||
|
|
||||||
# Add some tracks
|
|
||||||
track1_path = "/a/b/c"
|
|
||||||
track1 = Tracks(session, track1_path)
|
|
||||||
playlist_tab.insert_track(session, track1)
|
|
||||||
track2_path = "/d/e/f"
|
|
||||||
track2 = Tracks(session, track2_path)
|
|
||||||
playlist_tab.insert_track(session, track2)
|
|
||||||
track3_path = "/h/i/j"
|
|
||||||
track3 = Tracks(session, track3_path)
|
|
||||||
playlist_tab.insert_track(session, track3)
|
|
||||||
|
|
||||||
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(session, "my playlist")
|
|
||||||
playlist_tab = PlaylistTab(None, session, playlist)
|
|
||||||
|
|
||||||
# Add some tracks
|
|
||||||
track1_path = "/a/b/c"
|
|
||||||
track1 = Tracks(session, track1_path)
|
|
||||||
playlist_tab.insert_track(session, track1)
|
|
||||||
track2_path = "/d/e/f"
|
|
||||||
track2 = Tracks(session, track2_path)
|
|
||||||
playlist_tab.insert_track(session, track2)
|
|
||||||
track3_path = "/h/i/j"
|
|
||||||
track3 = Tracks(session, track3_path)
|
|
||||||
playlist_tab.insert_track(session, track3)
|
|
||||||
|
|
||||||
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 = 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 = Playlists(session, "my playlist")
|
|
||||||
playlist_tab = PlaylistTab(None, session, playlist)
|
|
||||||
|
|
||||||
# Add some tracks
|
|
||||||
track1_path = "/a/b/c"
|
|
||||||
track1 = Tracks(session, track1_path)
|
|
||||||
playlist_tab.insert_track(session, track1)
|
|
||||||
track2_path = "/d/e/f"
|
|
||||||
track2 = Tracks(session, track2_path)
|
|
||||||
playlist_tab.insert_track(session, track2)
|
|
||||||
|
|
||||||
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, session):
|
|
||||||
|
|
||||||
# Create playlist
|
|
||||||
playlist = Playlists(session, "my playlist")
|
|
||||||
playlist_tab = PlaylistTab(None, session, playlist)
|
|
||||||
|
|
||||||
# Add some tracks
|
|
||||||
track1_path = "/a/b/c"
|
|
||||||
track1 = Tracks(session, track1_path)
|
|
||||||
playlist_tab.insert_track(session, track1)
|
|
||||||
track2_path = "/d/e/f"
|
|
||||||
track2 = Tracks(session, track2_path)
|
|
||||||
playlist_tab.insert_track(session, track2)
|
|
||||||
|
|
||||||
window = Window()
|
|
||||||
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()
|
|
||||||
)
|
|
||||||
8
testdata/isa.py
vendored
8
testdata/isa.py
vendored
@ -1,8 +0,0 @@
|
|||||||
# Measurements for isa.{mp3,flac} (milliseconds)
|
|
||||||
|
|
||||||
{
|
|
||||||
"leading_silence": 60,
|
|
||||||
"fade_at": 236163,
|
|
||||||
"trailing_silence": 259373,
|
|
||||||
"duration": 262533,
|
|
||||||
}
|
|
||||||
6
testdata/mom.py
vendored
6
testdata/mom.py
vendored
@ -1,6 +0,0 @@
|
|||||||
# Tags for mom.py
|
|
||||||
|
|
||||||
{
|
|
||||||
"title": "Man of Mystery",
|
|
||||||
"artist": "The Shadows",
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue
Block a user