Compare commits
No commits in common. "a2fb6baba88b2347fb4aae57520f91cf8da02f4a" and "03735c24568a3c2f1c0a72b5f208e9ccd2c7f922" have entirely different histories.
a2fb6baba8
...
03735c2456
10
.envrc
10
.envrc
@ -1,11 +1 @@
|
||||
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"?>
|
||||
<project version="4">
|
||||
<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>
|
||||
@ -1,10 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="PYTHON_MODULE" version="4">
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<sourceFolder url="file://$MODULE_DIR$/app" isTestSource="false" />
|
||||
</content>
|
||||
<orderEntry type="jdk" jdkName="Poetry (musicmuster) (2)" jdkType="Python SDK" />
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
<component name="PyDocumentationSettings">
|
||||
|
||||
@ -39,10 +39,8 @@ prepend_sys_path = .
|
||||
# are written from script.py.mako
|
||||
# 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_dev
|
||||
# sqlalchemy.url = mysql+mysqldb://musicmuster:musicmuster@localhost/musicmuster_v2
|
||||
|
||||
[post_write_hooks]
|
||||
# post_write_hooks defines scripts or Python functions that are run
|
||||
|
||||
@ -3,43 +3,29 @@ import os
|
||||
|
||||
|
||||
class Config(object):
|
||||
AUDACITY_COMMAND = "/usr/bin/audacity"
|
||||
AUDIO_SEGMENT_CHUNK_SIZE = 10
|
||||
CHECK_AUDACITY_AT_STARTUP = True
|
||||
COLOUR_CURRENT_HEADER = "#d4edda"
|
||||
COLOUR_CURRENT_PLAYLIST = "#7eca8f"
|
||||
COLOUR_CURRENT_TAB = "#248f24"
|
||||
COLOUR_ODD_PLAYLIST = "#f2f2f2"
|
||||
COLOUR_ENDING_TIMER = "#dc3545"
|
||||
COLOUR_EVEN_PLAYLIST = "#d9d9d9"
|
||||
COLOUR_LONG_START = "#dc3545"
|
||||
COLOUR_NORMAL_TAB = "#000000"
|
||||
COLOUR_NEXT_HEADER = "#fff3cd"
|
||||
COLOUR_NEXT_PLAYLIST = "#ffc107"
|
||||
COLOUR_NEXT_TAB = "#b38600"
|
||||
COLOUR_NORMAL_TAB = "#000000"
|
||||
COLOUR_NOTES_PLAYLIST = "#b8daff"
|
||||
COLOUR_ODD_PLAYLIST = "#f2f2f2"
|
||||
COLOUR_PREVIOUS_HEADER = "#f8d7da"
|
||||
COLOUR_UNREADABLE = "#dc3545"
|
||||
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_SILENCE = -50
|
||||
DEFAULT_COLUMN_WIDTH = 200
|
||||
DEFAULT_IMPORT_DIRECTORY = "/home/kae/Nextcloud/tmp"
|
||||
DEFAULT_OUTPUT_DIRECTORY = "/home/kae/music/Singles"
|
||||
DISPLAY_SQL = False
|
||||
ERRORS_TO = ['kae@midnighthax.com']
|
||||
FADE_STEPS = 20
|
||||
FADE_TIME = 3000
|
||||
INFO_TAB_TITLE_LENGTH = 15
|
||||
INFO_TAB_URL = "https://www.wikipedia.org/w/index.php?search=%s"
|
||||
LOG_LEVEL_STDERR = logging.INFO
|
||||
LOG_LEVEL_SYSLOG = logging.DEBUG
|
||||
LOG_NAME = "musicmuster"
|
||||
@ -50,14 +36,18 @@ class Config(object):
|
||||
MAIL_USE_TLS = os.environ.get('MAIL_USE_TLS') is not None
|
||||
MAX_INFO_TABS = 3
|
||||
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
|
||||
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"
|
||||
TESTMODE = True
|
||||
TOD_TIME_FORMAT = "%H:%M:%S"
|
||||
TIMER_MS = 500
|
||||
TRACK_TIME_FORMAT = "%H:%M:%S"
|
||||
VOLUME_VLC_DEFAULT = 75
|
||||
|
||||
|
||||
|
||||
199
app/helpers.py
199
app/helpers.py
@ -1,78 +1,11 @@
|
||||
import os
|
||||
import psutil
|
||||
|
||||
from config import Config
|
||||
from datetime import datetime
|
||||
from pydub import AudioSegment
|
||||
from PyQt5.QtWidgets import QMessageBox
|
||||
from tinytag import TinyTag
|
||||
from typing import Dict, Optional, Union
|
||||
|
||||
|
||||
def ask_yes_no(title: str, question: str) -> bool:
|
||||
"""Ask question; return True for yes, False for no"""
|
||||
|
||||
button_reply: bool = QMessageBox.question(None, title, question)
|
||||
|
||||
return button_reply == QMessageBox.Yes
|
||||
|
||||
|
||||
def fade_point(
|
||||
audio_segment: AudioSegment, fade_threshold: int = 0,
|
||||
chunk_size: int = Config.AUDIO_SEGMENT_CHUNK_SIZE):
|
||||
"""
|
||||
Returns the millisecond/index of the point where the volume drops below
|
||||
the maximum and doesn't get louder again.
|
||||
audio_segment - the sdlg_search_database_uiegment to find silence in
|
||||
fade_threshold - the upper bound for how quiet is silent in dFBS
|
||||
chunk_size - chunk size for interating over the segment in ms
|
||||
"""
|
||||
|
||||
assert chunk_size > 0 # to avoid infinite loop
|
||||
|
||||
segment_length: int = audio_segment.duration_seconds * 1000 # ms
|
||||
trim_ms: int = segment_length - chunk_size
|
||||
max_vol: int = audio_segment.dBFS
|
||||
if fade_threshold == 0:
|
||||
fade_threshold = max_vol
|
||||
|
||||
while (
|
||||
audio_segment[trim_ms:trim_ms + chunk_size].dBFS < fade_threshold
|
||||
and trim_ms > 0): # noqa W503
|
||||
trim_ms -= chunk_size
|
||||
|
||||
# if there is no trailing silence, return lenght of track (it's less
|
||||
# the chunk_size, but for chunk_size = 10ms, this may be ignored)
|
||||
return int(trim_ms)
|
||||
|
||||
|
||||
def get_audio_segment(path: str) -> Optional[AudioSegment]:
|
||||
try:
|
||||
if path.endswith('.mp3'):
|
||||
return AudioSegment.from_mp3(path)
|
||||
elif path.endswith('.flac'):
|
||||
return AudioSegment.from_file(path, "flac")
|
||||
except AttributeError:
|
||||
return None
|
||||
|
||||
|
||||
def get_tags(path: str) -> Dict[str, Union[str, int]]:
|
||||
"""
|
||||
Return a dictionary of title, artist, duration-in-milliseconds and path.
|
||||
"""
|
||||
|
||||
tag: TinyTag = TinyTag.get(path)
|
||||
|
||||
return dict(
|
||||
title=tag.title,
|
||||
artist=tag.artist,
|
||||
duration=int(round(tag.duration, Config.MILLISECOND_SIGFIGS) * 1000),
|
||||
path=path
|
||||
)
|
||||
|
||||
|
||||
def get_relative_date(past_date: datetime, reference_date: datetime = None) \
|
||||
-> str:
|
||||
def get_relative_date(past_date, reference_date=None):
|
||||
"""
|
||||
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:
|
||||
return "get_relative_date() past_date is after relative_date"
|
||||
|
||||
days: int
|
||||
days_str: str
|
||||
weeks: int
|
||||
weeks_str: str
|
||||
|
||||
weeks, days = divmod((reference_date.date() - past_date.date()).days, 7)
|
||||
if weeks == days == 0:
|
||||
# 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"
|
||||
|
||||
|
||||
def leading_silence(
|
||||
audio_segment: AudioSegment,
|
||||
silence_threshold: int = Config.DBFS_SILENCE,
|
||||
chunk_size: int = Config.AUDIO_SEGMENT_CHUNK_SIZE):
|
||||
def open_in_audacity(path):
|
||||
"""
|
||||
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
|
||||
Open passed file in Audacity
|
||||
|
||||
https://github.com/jiaaro/pydub/blob/master/pydub/silence.py
|
||||
Return True if apparently opened successfully, else False
|
||||
"""
|
||||
|
||||
trim_ms: int = 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
|
||||
# Return if audacity not running
|
||||
if "audacity" not in [i.name() for i in psutil.process_iter()]:
|
||||
return False
|
||||
|
||||
# if there is no end it should return the length of the segment
|
||||
return min(trim_ms, len(audio_segment))
|
||||
to_pipe = '/tmp/audacity_script_pipe.to.' + str(os.getuid())
|
||||
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:
|
||||
"""Convert milliseconds to mm:ss"""
|
||||
def show_warning(title, msg):
|
||||
"Display a warning to user"
|
||||
|
||||
minutes: int
|
||||
remainder: int
|
||||
seconds: float
|
||||
QMessageBox.warning(None, title, msg, buttons=QMessageBox.Cancel)
|
||||
|
||||
|
||||
def ms_to_mmss(ms, decimals=0, negative=False):
|
||||
if not ms:
|
||||
return "-"
|
||||
sign = ""
|
||||
@ -161,66 +107,3 @@ def ms_to_mmss(ms: int, decimals: int = 0, negative: bool = False) -> str:
|
||||
seconds = 59.0
|
||||
|
||||
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):
|
||||
"""Add leveltag"""
|
||||
"Add leveltag"
|
||||
|
||||
def filter(self, record):
|
||||
# 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)
|
||||
|
||||
# Filter
|
||||
local_filter = LevelTagFilter()
|
||||
syslog.addFilter(local_filter)
|
||||
stderr.addFilter(local_filter)
|
||||
filter = LevelTagFilter()
|
||||
syslog.addFilter(filter)
|
||||
stderr.addFilter(filter)
|
||||
|
||||
# create formatter and add it to the handlers
|
||||
stderr_fmt = logging.Formatter('[%(asctime)s] %(leveltag)s: %(message)s',
|
||||
@ -103,6 +103,6 @@ if __name__ == "__main__":
|
||||
return i()
|
||||
|
||||
def i():
|
||||
return 1 / 0
|
||||
1 / 0
|
||||
|
||||
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)
|
||||
|
||||
with lock:
|
||||
DEBUG(f"music._fade(), stopping {p=}", True)
|
||||
DEBUG(f"music._facde(), stopping {p=}", True)
|
||||
|
||||
p.stop()
|
||||
DEBUG(f"Releasing player {p=}", True)
|
||||
p.release()
|
||||
# Ensure we don't reference player after release
|
||||
p = None
|
||||
|
||||
self.fading -= 1
|
||||
|
||||
def get_playtime(self):
|
||||
"""Return elapsed play time"""
|
||||
"Return elapsed play time"
|
||||
|
||||
with lock:
|
||||
if not self.player:
|
||||
@ -99,7 +101,7 @@ class Music:
|
||||
return self.player.get_time()
|
||||
|
||||
def get_position(self):
|
||||
"""Return current position"""
|
||||
"Return current position"
|
||||
|
||||
with lock:
|
||||
DEBUG("music.get_position", True)
|
||||
@ -145,13 +147,13 @@ class Music:
|
||||
return self.fading > 0
|
||||
|
||||
def set_position(self, ms):
|
||||
"""Set current play time in milliseconds from start"""
|
||||
"Set current play time in milliseconds from start"
|
||||
|
||||
with lock:
|
||||
return self.player.set_time(ms)
|
||||
|
||||
def set_volume(self, volume):
|
||||
"""Set maximum volume used for player"""
|
||||
"Set maximum volume used for player"
|
||||
|
||||
with lock:
|
||||
if not self.player:
|
||||
@ -161,7 +163,7 @@ class Music:
|
||||
self.player.audio_set_volume(volume)
|
||||
|
||||
def stop(self):
|
||||
"""Immediately stop playing"""
|
||||
"Immediately stop playing"
|
||||
|
||||
DEBUG(f"music.stop(), {self.player=}", True)
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
1739
app/playlists.py
1739
app/playlists.py
File diff suppressed because it is too large
Load Diff
@ -6,24 +6,27 @@ import shutil
|
||||
import tempfile
|
||||
|
||||
from config import Config
|
||||
from helpers import show_warning
|
||||
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.mp3 import MP3
|
||||
from pydub import effects
|
||||
from pydub import AudioSegment, effects
|
||||
from tinytag import TinyTag
|
||||
|
||||
# Globals (I know)
|
||||
messages = []
|
||||
|
||||
|
||||
def main():
|
||||
"""Main loop"""
|
||||
"Main loop"
|
||||
|
||||
DEBUG("Starting")
|
||||
|
||||
# Parse command line
|
||||
p = argparse.ArgumentParser()
|
||||
# Only allow one option to be specified
|
||||
group = p.add_mutually_exclusive_group()
|
||||
|
||||
group.add_argument('-u', '--update',
|
||||
action="store_true", dest="update",
|
||||
default=False, help="Update database")
|
||||
@ -62,9 +65,9 @@ def create_track_from_file(session, path, interactive=False):
|
||||
"""
|
||||
|
||||
if interactive:
|
||||
msg = f"Importing {path}"
|
||||
INFO(msg)
|
||||
INFO("-" * len(msg))
|
||||
str = f"Importing {path}"
|
||||
INFO(str)
|
||||
INFO("-" * len(str))
|
||||
INFO("Get track info...")
|
||||
t = get_music_info(path)
|
||||
title = t['title']
|
||||
@ -116,7 +119,7 @@ def create_track_from_file(session, path, interactive=False):
|
||||
fd, temp_path = tempfile.mkstemp()
|
||||
shutil.copyfile(path, temp_path)
|
||||
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
|
||||
|
||||
# Overwrite original file with normalised output
|
||||
@ -139,7 +142,7 @@ def create_track_from_file(session, path, interactive=False):
|
||||
dst[tag] = src[tag]
|
||||
dst.save()
|
||||
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
|
||||
shutil.copyfile(path, temp_path)
|
||||
finally:
|
||||
@ -150,7 +153,7 @@ def create_track_from_file(session, path, interactive=False):
|
||||
|
||||
|
||||
def full_update_db(session):
|
||||
"""Rescan all entries in database"""
|
||||
"Rescan all entries in database"
|
||||
|
||||
def log(msg):
|
||||
INFO(f"full_update_db(): {msg}")
|
||||
@ -220,6 +223,86 @@ def full_update_db(session):
|
||||
session.commit()
|
||||
|
||||
|
||||
def get_audio_segment(path):
|
||||
try:
|
||||
if path.endswith('.mp3'):
|
||||
return AudioSegment.from_mp3(path)
|
||||
elif path.endswith('.flac'):
|
||||
return AudioSegment.from_file(path, "flac")
|
||||
except AttributeError:
|
||||
return None
|
||||
|
||||
|
||||
def get_music_info(path):
|
||||
"""
|
||||
Return a dictionary of title, artist, duration-in-milliseconds and path.
|
||||
"""
|
||||
|
||||
tag = TinyTag.get(path)
|
||||
|
||||
return dict(
|
||||
title=tag.title,
|
||||
artist=tag.artist,
|
||||
duration=tag.duration,
|
||||
path=path
|
||||
)
|
||||
|
||||
|
||||
def leading_silence(audio_segment, silence_threshold=Config.DBFS_SILENCE,
|
||||
chunk_size=Config.AUDIO_SEGMENT_CHUNK_SIZE):
|
||||
"""
|
||||
Returns the millisecond/index that the leading silence ends.
|
||||
audio_segment - the segment to find silence in
|
||||
silence_threshold - the upper bound for how quiet is silent in dFBS
|
||||
chunk_size - chunk size for interating over the segment in ms
|
||||
|
||||
https://github.com/jiaaro/pydub/blob/master/pydub/silence.py
|
||||
"""
|
||||
|
||||
trim_ms = 0 # ms
|
||||
assert chunk_size > 0 # to avoid infinite loop
|
||||
while (
|
||||
audio_segment[trim_ms:trim_ms + chunk_size].dBFS < # noqa W504
|
||||
silence_threshold and trim_ms < len(audio_segment)):
|
||||
trim_ms += chunk_size
|
||||
|
||||
# if there is no end it should return the length of the segment
|
||||
return min(trim_ms, len(audio_segment))
|
||||
|
||||
|
||||
def fade_point(audio_segment, fade_threshold=0,
|
||||
chunk_size=Config.AUDIO_SEGMENT_CHUNK_SIZE):
|
||||
"""
|
||||
Returns the millisecond/index of the point where the volume drops below
|
||||
the maximum and doesn't get louder again.
|
||||
audio_segment - the sdlg_search_database_uiegment to find silence in
|
||||
fade_threshold - the upper bound for how quiet is silent in dFBS
|
||||
chunk_size - chunk size for interating over the segment in ms
|
||||
"""
|
||||
|
||||
assert chunk_size > 0 # to avoid infinite loop
|
||||
|
||||
segment_length = audio_segment.duration_seconds * 1000 # ms
|
||||
trim_ms = segment_length - chunk_size
|
||||
max_vol = audio_segment.dBFS
|
||||
if fade_threshold == 0:
|
||||
fade_threshold = max_vol
|
||||
|
||||
while (
|
||||
audio_segment[trim_ms:trim_ms + chunk_size].dBFS < fade_threshold
|
||||
and trim_ms > 0): # noqa W503
|
||||
trim_ms -= chunk_size
|
||||
|
||||
# if there is no trailing silence, return lenght of track (it's less
|
||||
# the chunk_size, but for chunk_size = 10ms, this may be ignored)
|
||||
return int(trim_ms)
|
||||
|
||||
|
||||
def trailing_silence(audio_segment, silence_threshold=-50.0,
|
||||
chunk_size=Config.AUDIO_SEGMENT_CHUNK_SIZE):
|
||||
return fade_point(audio_segment, silence_threshold, chunk_size)
|
||||
|
||||
|
||||
def update_db(session):
|
||||
"""
|
||||
Repopulate database
|
||||
@ -246,7 +329,7 @@ def update_db(session):
|
||||
for path in list(os_paths - db_paths):
|
||||
DEBUG(f"songdb.update_db: {path=} not 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:
|
||||
messages.append(f"Track missing from database: {path}")
|
||||
else:
|
||||
@ -262,7 +345,7 @@ def update_db(session):
|
||||
# Remote any tracks from database whose paths don't exist
|
||||
for path in list(db_paths - os_paths):
|
||||
# Manage tracks listed in database but where path is invalid
|
||||
track = Tracks.get_from_path(session, path)
|
||||
track = Tracks.get_track_from_path(session, path)
|
||||
messages.append(f"Remove from database: {path=} {track=}")
|
||||
|
||||
# Remove references from Playdates
|
||||
@ -273,15 +356,14 @@ def update_db(session):
|
||||
f"File removed: {track.title=}, {track.artist=}, "
|
||||
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
|
||||
Notes(session, playlist.id, pt.row, note_txt)
|
||||
# TODO: this needs to call playlist.add_note() now
|
||||
Notes.add_note(session, pt.playlist_id, pt.row, note_txt)
|
||||
# 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
|
||||
Tracks.remove_by_path(session, path)
|
||||
Tracks.remove_path(session, path)
|
||||
|
||||
# Output messages (so if running via cron, these will get sent to
|
||||
# user)
|
||||
@ -290,5 +372,44 @@ def update_db(session):
|
||||
print("\n".join(messages))
|
||||
|
||||
|
||||
def update_meta(session, track, artist=None, title=None):
|
||||
"""
|
||||
Updates both the tag info in the file and the database entry with passed
|
||||
artist and tag details.
|
||||
"""
|
||||
|
||||
DEBUG(f"songdb.update_meta({session=}, {track=}, {artist=}, {title=})")
|
||||
|
||||
if not artist and not title:
|
||||
return
|
||||
|
||||
ftype = os.path.splitext(track.path)[1][1:]
|
||||
if ftype == 'flac':
|
||||
tag_handler = FLAC
|
||||
elif ftype == 'mp3':
|
||||
tag_handler = MP3
|
||||
else:
|
||||
INFO(f"File type {ftype} not implemented")
|
||||
return
|
||||
|
||||
# Update tags
|
||||
f = tag_handler(track.path)
|
||||
try:
|
||||
if artist:
|
||||
f["artist"] = artist
|
||||
if title:
|
||||
f["title"] = title
|
||||
f.save()
|
||||
except TypeError:
|
||||
show_warning("TAG error", "Can't update tag. Try editing in Audacity")
|
||||
|
||||
# Update database
|
||||
with Session() as session:
|
||||
if artist:
|
||||
Tracks.update_artist(session, track.id, artist)
|
||||
if title:
|
||||
Tracks.update_title(session, track.id, title)
|
||||
|
||||
|
||||
if __name__ == '__main__' and '__file__' in globals():
|
||||
main()
|
||||
@ -175,7 +175,7 @@ border: 1px solid rgb(85, 87, 83);</string>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="hdrNextTrack">
|
||||
<widget class="ElideLabel" name="hdrNextTrack">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>0</width>
|
||||
@ -794,9 +794,19 @@ border: 1px solid rgb(85, 87, 83);</string>
|
||||
<addaction name="separator"/>
|
||||
<addaction name="actionSetNext"/>
|
||||
</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="menuPlaylist"/>
|
||||
<addaction name="menu_Tracks"/>
|
||||
<addaction name="menuTest"/>
|
||||
</widget>
|
||||
<widget class="QStatusBar" name="statusbar">
|
||||
<property name="enabled">
|
||||
@ -1004,6 +1014,13 @@ border: 1px solid rgb(85, 87, 83);</string>
|
||||
</property>
|
||||
</action>
|
||||
</widget>
|
||||
<customwidgets>
|
||||
<customwidget>
|
||||
<class>ElideLabel</class>
|
||||
<extends>QLabel</extends>
|
||||
<header>musicmuster</header>
|
||||
</customwidget>
|
||||
</customwidgets>
|
||||
<resources>
|
||||
<include location="icons.qrc"/>
|
||||
</resources>
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
# 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
|
||||
# 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.setObjectName("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.setMaximumSize(QtCore.QSize(16777215, 39))
|
||||
font = QtGui.QFont()
|
||||
@ -356,6 +356,8 @@ class Ui_MainWindow(object):
|
||||
self.menuPlaylist.setObjectName("menuPlaylist")
|
||||
self.menu_Tracks = QtWidgets.QMenu(self.menubar)
|
||||
self.menu_Tracks.setObjectName("menu_Tracks")
|
||||
self.menuTest = QtWidgets.QMenu(self.menubar)
|
||||
self.menuTest.setObjectName("menuTest")
|
||||
MainWindow.setMenuBar(self.menubar)
|
||||
self.statusbar = QtWidgets.QStatusBar(MainWindow)
|
||||
self.statusbar.setEnabled(True)
|
||||
@ -465,13 +467,18 @@ class Ui_MainWindow(object):
|
||||
self.menu_Tracks.addAction(self.action_Resume_previous)
|
||||
self.menu_Tracks.addSeparator()
|
||||
self.menu_Tracks.addAction(self.actionSetNext)
|
||||
self.menuTest.addAction(self.actionTestFunction)
|
||||
self.menuTest.addSeparator()
|
||||
self.menuTest.addAction(self.actionSkipToFade)
|
||||
self.menuTest.addAction(self.actionSkipToEnd)
|
||||
self.menubar.addAction(self.menuFile.menuAction())
|
||||
self.menubar.addAction(self.menuPlaylist.menuAction())
|
||||
self.menubar.addAction(self.menu_Tracks.menuAction())
|
||||
self.menubar.addAction(self.menuTest.menuAction())
|
||||
|
||||
self.retranslateUi(MainWindow)
|
||||
self.tabPlaylist.setCurrentIndex(-1)
|
||||
self.actionE_xit.triggered.connect(MainWindow.close) # type: ignore
|
||||
self.actionE_xit.triggered.connect(MainWindow.close)
|
||||
QtCore.QMetaObject.connectSlotsByName(MainWindow)
|
||||
|
||||
def retranslateUi(self, MainWindow):
|
||||
@ -506,6 +513,7 @@ class Ui_MainWindow(object):
|
||||
self.menuFile.setTitle(_translate("MainWindow", "Fi&le"))
|
||||
self.menuPlaylist.setTitle(_translate("MainWindow", "Pla&ylist"))
|
||||
self.menu_Tracks.setTitle(_translate("MainWindow", "&Tracks"))
|
||||
self.menuTest.setTitle(_translate("MainWindow", "TestMo&de"))
|
||||
self.actionPlay_next.setText(_translate("MainWindow", "&Play next"))
|
||||
self.actionPlay_next.setShortcut(_translate("MainWindow", "Return"))
|
||||
self.actionSkip_next.setText(_translate("MainWindow", "Skip to &next"))
|
||||
@ -542,4 +550,5 @@ class Ui_MainWindow(object):
|
||||
self.actionSelect_unplayed_tracks.setText(_translate("MainWindow", "Select unplayed tracks"))
|
||||
self.actionAdd_note.setText(_translate("MainWindow", "Add note..."))
|
||||
self.actionAdd_note.setShortcut(_translate("MainWindow", "Ctrl+T"))
|
||||
from musicmuster import ElideLabel
|
||||
import icons_rc
|
||||
|
||||
@ -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__))
|
||||
sys.path.insert(0, path)
|
||||
sys.path.insert(0, os.path.join(path, "app"))
|
||||
from app.models import Base
|
||||
from app.model import Base
|
||||
target_metadata = Base.metadata
|
||||
# other values from the config, defined by the needs of env.py,
|
||||
# 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]]
|
||||
name = "alembic"
|
||||
version = "1.7.6"
|
||||
version = "1.7.5"
|
||||
description = "A database migration tool for SQLAlchemy."
|
||||
category = "main"
|
||||
optional = false
|
||||
@ -158,11 +158,11 @@ lingua = ["lingua"]
|
||||
|
||||
[[package]]
|
||||
name = "markupsafe"
|
||||
version = "2.1.0"
|
||||
version = "2.0.1"
|
||||
description = "Safely add untrusted strings to HTML/XML markup."
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
python-versions = ">=3.6"
|
||||
|
||||
[[package]]
|
||||
name = "matplotlib-inline"
|
||||
@ -187,7 +187,7 @@ python-versions = ">=3.5, <4"
|
||||
name = "mypy"
|
||||
version = "0.931"
|
||||
description = "Optional static typing for Python"
|
||||
category = "main"
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
|
||||
@ -204,7 +204,7 @@ python2 = ["typed-ast (>=1.4.0,<2)"]
|
||||
name = "mypy-extensions"
|
||||
version = "0.4.3"
|
||||
description = "Experimental type system extensions for programs checked with the mypy typechecker."
|
||||
category = "main"
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
|
||||
@ -326,7 +326,7 @@ python-versions = "*"
|
||||
|
||||
[[package]]
|
||||
name = "pyqt5-sip"
|
||||
version = "12.9.1"
|
||||
version = "12.9.0"
|
||||
description = "The sip module support for PyQt5"
|
||||
category = "main"
|
||||
optional = false
|
||||
@ -353,42 +353,6 @@ category = "main"
|
||||
optional = false
|
||||
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]]
|
||||
name = "python-vlc"
|
||||
version = "3.0.12118"
|
||||
@ -476,7 +440,7 @@ python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
|
||||
name = "tomli"
|
||||
version = "2.0.1"
|
||||
description = "A lil' TOML parser"
|
||||
category = "main"
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
|
||||
@ -495,7 +459,7 @@ test = ["pytest"]
|
||||
name = "typing-extensions"
|
||||
version = "4.0.1"
|
||||
description = "Backported and Experimental Type Hints for Python 3.6+"
|
||||
category = "main"
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
|
||||
@ -514,8 +478,8 @@ content-hash = "0c1303cb7e23bd0c24c31b08e727cfe278bc6bdaa2ac3450a8c689c2ee7b74f2
|
||||
|
||||
[metadata.files]
|
||||
alembic = [
|
||||
{file = "alembic-1.7.6-py3-none-any.whl", hash = "sha256:ad842f2c3ab5c5d4861232730779c05e33db4ba880a08b85eb505e87c01095bc"},
|
||||
{file = "alembic-1.7.6.tar.gz", hash = "sha256:6c0c05e9768a896d804387e20b299880fe01bc56484246b0dffe8075d6d3d847"},
|
||||
{file = "alembic-1.7.5-py3-none-any.whl", hash = "sha256:a9dde941534e3d7573d9644e8ea62a2953541e27bc1793e166f60b777ae098b4"},
|
||||
{file = "alembic-1.7.5.tar.gz", hash = "sha256:7c328694a2e68f03ee971e63c3bd885846470373a5b532cf2c9f1601c413b153"},
|
||||
]
|
||||
appnope = [
|
||||
{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"},
|
||||
]
|
||||
markupsafe = [
|
||||
{file = "MarkupSafe-2.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3028252424c72b2602a323f70fbf50aa80a5d3aa616ea6add4ba21ae9cc9da4c"},
|
||||
{file = "MarkupSafe-2.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:290b02bab3c9e216da57c1d11d2ba73a9f73a614bbdcc027d299a60cdfabb11a"},
|
||||
{file = "MarkupSafe-2.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e104c0c2b4cd765b4e83909cde7ec61a1e313f8a75775897db321450e928cce"},
|
||||
{file = "MarkupSafe-2.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24c3be29abb6b34052fd26fc7a8e0a49b1ee9d282e3665e8ad09a0a68faee5b3"},
|
||||
{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.1.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d3b64c65328cb4cd252c94f83e66e3d7acf8891e60ebf588d7b493a55a1dbf26"},
|
||||
{file = "MarkupSafe-2.1.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:96de1932237abe0a13ba68b63e94113678c379dca45afa040a17b6e1ad7ed076"},
|
||||
{file = "MarkupSafe-2.1.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:75bb36f134883fdbe13d8e63b8675f5f12b80bb6627f7714c7d6c5becf22719f"},
|
||||
{file = "MarkupSafe-2.1.0-cp310-cp310-win32.whl", hash = "sha256:4056f752015dfa9828dce3140dbadd543b555afb3252507348c493def166d454"},
|
||||
{file = "MarkupSafe-2.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:d4e702eea4a2903441f2735799d217f4ac1b55f7d8ad96ab7d4e25417cb0827c"},
|
||||
{file = "MarkupSafe-2.1.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:f0eddfcabd6936558ec020130f932d479930581171368fd728efcfb6ef0dd357"},
|
||||
{file = "MarkupSafe-2.1.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ddea4c352a488b5e1069069f2f501006b1a4362cb906bee9a193ef1245a7a61"},
|
||||
{file = "MarkupSafe-2.1.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:09c86c9643cceb1d87ca08cdc30160d1b7ab49a8a21564868921959bd16441b8"},
|
||||
{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.1.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:736895a020e31b428b3382a7887bfea96102c529530299f426bf2e636aacec9e"},
|
||||
{file = "MarkupSafe-2.1.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:679cbb78914ab212c49c67ba2c7396dc599a8479de51b9a87b174700abd9ea49"},
|
||||
{file = "MarkupSafe-2.1.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:84ad5e29bf8bab3ad70fd707d3c05524862bddc54dc040982b0dbcff36481de7"},
|
||||
{file = "MarkupSafe-2.1.0-cp37-cp37m-win32.whl", hash = "sha256:8da5924cb1f9064589767b0f3fc39d03e3d0fb5aa29e0cb21d43106519bd624a"},
|
||||
{file = "MarkupSafe-2.1.0-cp37-cp37m-win_amd64.whl", hash = "sha256:454ffc1cbb75227d15667c09f164a0099159da0c1f3d2636aa648f12675491ad"},
|
||||
{file = "MarkupSafe-2.1.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:142119fb14a1ef6d758912b25c4e803c3ff66920635c44078666fe7cc3f8f759"},
|
||||
{file = "MarkupSafe-2.1.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b2a5a856019d2833c56a3dcac1b80fe795c95f401818ea963594b345929dffa7"},
|
||||
{file = "MarkupSafe-2.1.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d1fb9b2eec3c9714dd936860850300b51dbaa37404209c8d4cb66547884b7ed"},
|
||||
{file = "MarkupSafe-2.1.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:62c0285e91414f5c8f621a17b69fc0088394ccdaa961ef469e833dbff64bd5ea"},
|
||||
{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.1.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:f02cf7221d5cd915d7fa58ab64f7ee6dd0f6cddbb48683debf5d04ae9b1c2cc1"},
|
||||
{file = "MarkupSafe-2.1.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:d5653619b3eb5cbd35bfba3c12d575db2a74d15e0e1c08bf1db788069d410ce8"},
|
||||
{file = "MarkupSafe-2.1.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:7d2f5d97fcbd004c03df8d8fe2b973fe2b14e7bfeb2cfa012eaa8759ce9a762f"},
|
||||
{file = "MarkupSafe-2.1.0-cp38-cp38-win32.whl", hash = "sha256:3cace1837bc84e63b3fd2dfce37f08f8c18aeb81ef5cf6bb9b51f625cb4e6cd8"},
|
||||
{file = "MarkupSafe-2.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:fabbe18087c3d33c5824cb145ffca52eccd053061df1d79d4b66dafa5ad2a5ea"},
|
||||
{file = "MarkupSafe-2.1.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:023af8c54fe63530545f70dd2a2a7eed18d07a9a77b94e8bf1e2ff7f252db9a3"},
|
||||
{file = "MarkupSafe-2.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d66624f04de4af8bbf1c7f21cc06649c1c69a7f84109179add573ce35e46d448"},
|
||||
{file = "MarkupSafe-2.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c532d5ab79be0199fa2658e24a02fce8542df196e60665dd322409a03db6a52c"},
|
||||
{file = "MarkupSafe-2.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e67ec74fada3841b8c5f4c4f197bea916025cb9aa3fe5abf7d52b655d042f956"},
|
||||
{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.1.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:961eb86e5be7d0973789f30ebcf6caab60b844203f4396ece27310295a6082c7"},
|
||||
{file = "MarkupSafe-2.1.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:598b65d74615c021423bd45c2bc5e9b59539c875a9bdb7e5f2a6b92dfcfc268d"},
|
||||
{file = "MarkupSafe-2.1.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:599941da468f2cf22bf90a84f6e2a65524e87be2fce844f96f2dd9a6c9d1e635"},
|
||||
{file = "MarkupSafe-2.1.0-cp39-cp39-win32.whl", hash = "sha256:e6f7f3f41faffaea6596da86ecc2389672fa949bd035251eab26dc6697451d05"},
|
||||
{file = "MarkupSafe-2.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:b8811d48078d1cf2a6863dafb896e68406c5f513048451cd2ded0473133473c7"},
|
||||
{file = "MarkupSafe-2.1.0.tar.gz", hash = "sha256:80beaf63ddfbc64a0452b841d8036ca0611e049650e20afcb882f5d3c266d65f"},
|
||||
{file = "MarkupSafe-2.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d8446c54dc28c01e5a2dbac5a25f071f6653e6e40f3a8818e8b45d790fe6ef53"},
|
||||
{file = "MarkupSafe-2.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:36bc903cbb393720fad60fc28c10de6acf10dc6cc883f3e24ee4012371399a38"},
|
||||
{file = "MarkupSafe-2.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2d7d807855b419fc2ed3e631034685db6079889a1f01d5d9dac950f764da3dad"},
|
||||
{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.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.0.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:4dc8f9fb58f7364b63fd9f85013b780ef83c11857ae79f2feda41e270468dd9b"},
|
||||
{file = "MarkupSafe-2.0.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:20dca64a3ef2d6e4d5d615a3fd418ad3bde77a47ec8a23d984a12b5b4c74491a"},
|
||||
{file = "MarkupSafe-2.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:cdfba22ea2f0029c9261a4bd07e830a8da012291fbe44dc794e488b6c9bb353a"},
|
||||
{file = "MarkupSafe-2.0.1-cp310-cp310-win32.whl", hash = "sha256:99df47edb6bda1249d3e80fdabb1dab8c08ef3975f69aed437cb69d0a5de1e28"},
|
||||
{file = "MarkupSafe-2.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:e0f138900af21926a02425cf736db95be9f4af72ba1bb21453432a07f6082134"},
|
||||
{file = "MarkupSafe-2.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f9081981fe268bd86831e5c75f7de206ef275defcb82bc70740ae6dc507aee51"},
|
||||
{file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:0955295dd5eec6cb6cc2fe1698f4c6d84af2e92de33fbcac4111913cd100a6ff"},
|
||||
{file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:0446679737af14f45767963a1a9ef7620189912317d095f2d9ffa183a4d25d2b"},
|
||||
{file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:f826e31d18b516f653fe296d967d700fddad5901ae07c622bb3705955e1faa94"},
|
||||
{file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:fa130dd50c57d53368c9d59395cb5526eda596d3ffe36666cd81a44d56e48872"},
|
||||
{file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:905fec760bd2fa1388bb5b489ee8ee5f7291d692638ea5f67982d968366bef9f"},
|
||||
{file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf5d821ffabf0ef3533c39c518f3357b171a1651c1ff6827325e4489b0e46c3c"},
|
||||
{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.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.0.1-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:deb993cacb280823246a026e3b2d81c493c53de6acfd5e6bfe31ab3402bb37dd"},
|
||||
{file = "MarkupSafe-2.0.1-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:63f3268ba69ace99cab4e3e3b5840b03340efed0948ab8f78d2fd87ee5442a4f"},
|
||||
{file = "MarkupSafe-2.0.1-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:8d206346619592c6200148b01a2142798c989edcb9c896f9ac9722a99d4e77e6"},
|
||||
{file = "MarkupSafe-2.0.1-cp36-cp36m-win32.whl", hash = "sha256:6c4ca60fa24e85fe25b912b01e62cb969d69a23a5d5867682dd3e80b5b02581d"},
|
||||
{file = "MarkupSafe-2.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b2f4bf27480f5e5e8ce285a8c8fd176c0b03e93dcc6646477d4630e83440c6a9"},
|
||||
{file = "MarkupSafe-2.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0717a7390a68be14b8c793ba258e075c6f4ca819f15edfc2a3a027c823718567"},
|
||||
{file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:6557b31b5e2c9ddf0de32a691f2312a32f77cd7681d8af66c2692efdbef84c18"},
|
||||
{file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:49e3ceeabbfb9d66c3aef5af3a60cc43b85c33df25ce03d0031a608b0a8b2e3f"},
|
||||
{file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:d7f9850398e85aba693bb640262d3611788b1f29a79f0c93c565694658f4071f"},
|
||||
{file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:6a7fae0dd14cf60ad5ff42baa2e95727c3d81ded453457771d02b7d2b3f9c0c2"},
|
||||
{file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:b7f2d075102dc8c794cbde1947378051c4e5180d52d276987b8d28a3bd58c17d"},
|
||||
{file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e9936f0b261d4df76ad22f8fee3ae83b60d7c3e871292cd42f40b81b70afae85"},
|
||||
{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.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.0.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d6c7ebd4e944c85e2c3421e612a7057a2f48d478d79e61800d81468a8d842207"},
|
||||
{file = "MarkupSafe-2.0.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:f0567c4dc99f264f49fe27da5f735f414c4e7e7dd850cfd8e69f0862d7c74ea9"},
|
||||
{file = "MarkupSafe-2.0.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:89c687013cb1cd489a0f0ac24febe8c7a666e6e221b783e53ac50ebf68e45d86"},
|
||||
{file = "MarkupSafe-2.0.1-cp37-cp37m-win32.whl", hash = "sha256:a30e67a65b53ea0a5e62fe23682cfe22712e01f453b95233b25502f7c61cb415"},
|
||||
{file = "MarkupSafe-2.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:611d1ad9a4288cf3e3c16014564df047fe08410e628f89805e475368bd304914"},
|
||||
{file = "MarkupSafe-2.0.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:5bb28c636d87e840583ee3adeb78172efc47c8b26127267f54a9c0ec251d41a9"},
|
||||
{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 = [
|
||||
{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"},
|
||||
]
|
||||
pyqt5-sip = [
|
||||
{file = "PyQt5_sip-12.9.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:6b2e553e21b7ff124007a6b9168f8bb8c171fdf230d31ca0588df180f10bacbe"},
|
||||
{file = "PyQt5_sip-12.9.1-cp310-cp310-manylinux1_x86_64.whl", hash = "sha256:5740a1770d6b92a5dca8bb0bda4620baf0d7a726beb864f69c667ddac91d6f64"},
|
||||
{file = "PyQt5_sip-12.9.1-cp310-cp310-win32.whl", hash = "sha256:9699286fcdf4f75a4b91c7e4832c0f926af18d648c62a4ed72dd294c1a93705a"},
|
||||
{file = "PyQt5_sip-12.9.1-cp310-cp310-win_amd64.whl", hash = "sha256:e2792af660da7479799f53028da88190ae8b4a0ad5acc2acbfd6c7bbfe110d58"},
|
||||
{file = "PyQt5_sip-12.9.1-cp36-cp36m-macosx_10_6_intel.whl", hash = "sha256:7ee08ad0ebf85b935f5d8d38306f8665fff9a6026c14fc0a7d780649e889c096"},
|
||||
{file = "PyQt5_sip-12.9.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:2d21420b0739df2607864e2c80ca01994bc40cb116da6ad024ea8d9f407b0356"},
|
||||
{file = "PyQt5_sip-12.9.1-cp36-cp36m-win32.whl", hash = "sha256:ffd25051962c593d1c3c30188b9fbd8589ba17acd23a0202dc987bd3552fa611"},
|
||||
{file = "PyQt5_sip-12.9.1-cp36-cp36m-win_amd64.whl", hash = "sha256:78ef8f1f41819661aa8e3117d6c1cd76fa14aef265e5bfd515dbfc64d412416b"},
|
||||
{file = "PyQt5_sip-12.9.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:5e641182bfee0501267c55e687832e4efe05becdae9e555d3695d706009b6598"},
|
||||
{file = "PyQt5_sip-12.9.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:c9a977d2835a5fbf250b00d61267dc228bdec9e20c7420d4e8d54d6f20410f87"},
|
||||
{file = "PyQt5_sip-12.9.1-cp37-cp37m-win32.whl", hash = "sha256:cec6ebf0b1163b18f09bc523160c467a9528b6dca129753827ac0bc432b332ae"},
|
||||
{file = "PyQt5_sip-12.9.1-cp37-cp37m-win_amd64.whl", hash = "sha256:82c1b3080db7634fa318fddbb3cfaa30e63a67bca1001a76958c31f30b774a9d"},
|
||||
{file = "PyQt5_sip-12.9.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cfaad4a773c18b963092589b1a98153d36624601de8597a4dc287e5a295d5625"},
|
||||
{file = "PyQt5_sip-12.9.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:ce7a8b3af9db378c46b345d9809d481a74c4bfcd3129486c054fbdbc6a3503f9"},
|
||||
{file = "PyQt5_sip-12.9.1-cp38-cp38-win32.whl", hash = "sha256:8fe5b3e4bbb8b472d05631cad21028d073f9f8eda770041449514cb3824a867f"},
|
||||
{file = "PyQt5_sip-12.9.1-cp38-cp38-win_amd64.whl", hash = "sha256:5d59c4a5e856a35c41b47f5a23e1635b38cd1672f4f0122a68ebcb6889523ff2"},
|
||||
{file = "PyQt5_sip-12.9.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b56aedf7b0a496e4a8bd6087566888cea448aa01c76126cdb8b140e3ff3f5d93"},
|
||||
{file = "PyQt5_sip-12.9.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:53e23dcc0fc3857204abd47660e383b930941bd1aeaf3c78ed59c5c12dd48010"},
|
||||
{file = "PyQt5_sip-12.9.1-cp39-cp39-win32.whl", hash = "sha256:ee188eac5fd94dfe8d9e04a9e7fbda65c3535d5709278d8b7367ebd54f00e27f"},
|
||||
{file = "PyQt5_sip-12.9.1-cp39-cp39-win_amd64.whl", hash = "sha256:989d51c41456cc496cb96f0b341464932b957040d26561f0bb4cf5a0914d6b36"},
|
||||
{file = "PyQt5_sip-12.9.1.tar.gz", hash = "sha256:2f24f299b44c511c23796aafbbb581bfdebf78d0905657b7cee2141b4982030e"},
|
||||
{file = "PyQt5_sip-12.9.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:6d5bca2fc222d58e8093ee8a81a6e3437067bb22bc3f86d06ec8be721e15e90a"},
|
||||
{file = "PyQt5_sip-12.9.0-cp310-cp310-manylinux1_x86_64.whl", hash = "sha256:d59af63120d1475b2bf94fe8062610720a9be1e8940ea146c7f42bb449d49067"},
|
||||
{file = "PyQt5_sip-12.9.0-cp310-cp310-win32.whl", hash = "sha256:0fc9aefacf502696710b36cdc9fa2a61487f55ee883dbcf2c2a6477e261546f7"},
|
||||
{file = "PyQt5_sip-12.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:485972daff2fb0311013f471998f8ec8262ea381bded244f9d14edaad5f54271"},
|
||||
{file = "PyQt5_sip-12.9.0-cp36-cp36m-macosx_10_6_intel.whl", hash = "sha256:d85002238b5180bce4b245c13d6face848faa1a7a9e5c6e292025004f2fd619a"},
|
||||
{file = "PyQt5_sip-12.9.0-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:83c3220b1ca36eb8623ba2eb3766637b19eb0ce9f42336ad8253656d32750c0a"},
|
||||
{file = "PyQt5_sip-12.9.0-cp36-cp36m-win32.whl", hash = "sha256:d8b2bdff7bbf45bc975c113a03b14fd669dc0c73e1327f02706666a7dd51a197"},
|
||||
{file = "PyQt5_sip-12.9.0-cp36-cp36m-win_amd64.whl", hash = "sha256:69a3ad4259172e2b1aa9060de211efac39ddd734a517b1924d9c6c0cc4f55f96"},
|
||||
{file = "PyQt5_sip-12.9.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:42274a501ab4806d2c31659170db14c282b8313d2255458064666d9e70d96206"},
|
||||
{file = "PyQt5_sip-12.9.0-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:6a8701892a01a5a2a4720872361197cc80fdd5f49c8482d488ddf38c9c84f055"},
|
||||
{file = "PyQt5_sip-12.9.0-cp37-cp37m-win32.whl", hash = "sha256:ac57d796c78117eb39edd1d1d1aea90354651efac9d3590aac67fa4983f99f1f"},
|
||||
{file = "PyQt5_sip-12.9.0-cp37-cp37m-win_amd64.whl", hash = "sha256:4347bd81d30c8e3181e553b3734f91658cfbdd8f1a19f254777f906870974e6d"},
|
||||
{file = "PyQt5_sip-12.9.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:c446971c360a0a1030282a69375a08c78e8a61d568bfd6dab3dcc5cf8817f644"},
|
||||
{file = "PyQt5_sip-12.9.0-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:fc43f2d7c438517ee33e929e8ae77132749c15909afab6aeece5fcf4147ffdb5"},
|
||||
{file = "PyQt5_sip-12.9.0-cp38-cp38-win32.whl", hash = "sha256:055581c6fed44ba4302b70eeb82e979ff70400037358908f251cd85cbb3dbd93"},
|
||||
{file = "PyQt5_sip-12.9.0-cp38-cp38-win_amd64.whl", hash = "sha256:c5216403d4d8d857ec4a61f631d3945e44fa248aa2415e9ee9369ab7c8a4d0c7"},
|
||||
{file = "PyQt5_sip-12.9.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a25b9843c7da6a1608f310879c38e6434331aab1dc2fe6cb65c14f1ecf33780e"},
|
||||
{file = "PyQt5_sip-12.9.0-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:dd05c768c2b55ffe56a9d49ce6cc77cdf3d53dbfad935258a9e347cbfd9a5850"},
|
||||
{file = "PyQt5_sip-12.9.0-cp39-cp39-win32.whl", hash = "sha256:4f8e05fe01d54275877c59018d8e82dcdd0bc5696053a8b830eecea3ce806121"},
|
||||
{file = "PyQt5_sip-12.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:b09f4cd36a4831229fb77c424d89635fa937d97765ec90685e2f257e56a2685a"},
|
||||
{file = "PyQt5_sip-12.9.0.tar.gz", hash = "sha256:d3e4489d7c2b0ece9d203ae66e573939f7f60d4d29e089c9f11daa17cfeaae32"},
|
||||
]
|
||||
pyqtwebengine = [
|
||||
{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-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 = [
|
||||
{file = "python-vlc-3.0.12118.tar.gz", hash = "sha256:566f2f7c303f6800851cacc016df1c6eeec094ad63e0a49d87db9d698094f1fb"},
|
||||
{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.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"
|
||||
PyQtWebEngine = "^5.15.5"
|
||||
pydub = "^0.25.1"
|
||||
PyQt5-sip = "^12.9.1"
|
||||
mypy = "^0.931"
|
||||
sqlalchemy-stubs = "^0.4"
|
||||
PyQt5-sip = "^12.9.0"
|
||||
|
||||
[tool.poetry.dev-dependencies]
|
||||
mypy = "^0.931"
|
||||
pytest = "^7.0.0"
|
||||
ipdb = "^0.13.9"
|
||||
sqlalchemy-stubs = "^0.4"
|
||||
pytest-qt = "^4.0.2"
|
||||
|
||||
[build-system]
|
||||
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