Compare commits

..

No commits in common. "dff7e2323d155378dc82b65ec45f38dbef1cdb68" and "8192e79d426d0e006a106dc5bff87e8723f2f5ac" have entirely different histories.

29 changed files with 2503 additions and 3387 deletions

2
.envrc
View File

@ -2,8 +2,6 @@ layout poetry
branch=$(git branch --show-current) branch=$(git branch --show-current)
if on_git_branch master; then if on_git_branch master; then
export MM_ENV="PRODUCTION" export MM_ENV="PRODUCTION"
export MM_DB="mysql+mysqldb://musicmuster:musicmuster@localhost/musicmuster_prod"
else MYSQL_DATABASE="musicmuster_dev" else MYSQL_DATABASE="musicmuster_dev"
export MM_ENV="DEVELOPMENT" export MM_ENV="DEVELOPMENT"
export MM_DB="mysql+mysqldb://musicmusterv3:musicmusterv3@localhost/musicmuster_dev_v3"
fi fi

View File

@ -43,7 +43,6 @@ sqlalchemy.url = SET
# sqlalchemy.url = mysql+mysqldb://musicmuster:musicmuster@localhost/musicmuster_prod # sqlalchemy.url = mysql+mysqldb://musicmuster:musicmuster@localhost/musicmuster_prod
# sqlalchemy.url = mysql+mysqldb://musicmuster:musicmuster@localhost/musicmuster_dev # sqlalchemy.url = mysql+mysqldb://musicmuster:musicmuster@localhost/musicmuster_dev
# sqlalchemy.url = mysql+mysqldb://musicmuster:musicmuster@localhost/musicmuster_v2 # sqlalchemy.url = mysql+mysqldb://musicmuster:musicmuster@localhost/musicmuster_v2
# sqlalchemy.url = mysql+mysqldb://musicmusterv3:musicmusterv3@localhost/musicmuster_dev_v3
[post_write_hooks] [post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run # post_write_hooks defines scripts or Python functions that are run

View File

@ -1,17 +1,11 @@
import logging import logging
import os import os
from typing import List, Optional
class Config(object): class Config(object):
AUDACITY_COMMAND = "/usr/bin/audacity" AUDACITY_COMMAND = "/usr/bin/audacity"
AUDIO_SEGMENT_CHUNK_SIZE = 10 AUDIO_SEGMENT_CHUNK_SIZE = 10
BITRATE_LOW_THRESHOLD = 192
BITRATE_OK_THRESHOLD = 300
CHECK_AUDACITY_AT_STARTUP = True CHECK_AUDACITY_AT_STARTUP = True
COLOUR_BITRATE_LOW = "#ffcdd2"
COLOUR_BITRATE_MEDIUM = "#ffeb6f"
COLOUR_BITRATE_OK = "#dcedc8"
COLOUR_CURRENT_HEADER = "#d4edda" COLOUR_CURRENT_HEADER = "#d4edda"
COLOUR_CURRENT_PLAYLIST = "#7eca8f" COLOUR_CURRENT_PLAYLIST = "#7eca8f"
COLOUR_CURRENT_TAB = "#248f24" COLOUR_CURRENT_TAB = "#248f24"
@ -29,18 +23,14 @@ class Config(object):
COLOUR_WARNING_TIMER = "#ffc107" COLOUR_WARNING_TIMER = "#ffc107"
COLUMN_NAME_ARTIST = "Artist" COLUMN_NAME_ARTIST = "Artist"
COLUMN_NAME_AUTOPLAY = "A" COLUMN_NAME_AUTOPLAY = "A"
COLUMN_NAME_BITRATE = "bps"
COLUMN_NAME_END_TIME = "End" COLUMN_NAME_END_TIME = "End"
COLUMN_NAME_LAST_PLAYED = "Last played" COLUMN_NAME_LAST_PLAYED = "Last played"
COLUMN_NAME_LEADING_SILENCE = "Gap" COLUMN_NAME_LEADING_SILENCE = "Gap"
COLUMN_NAME_LENGTH = "Length" COLUMN_NAME_LENGTH = "Length"
COLUMN_NAME_NOTES = "Notes"
COLUMN_NAME_START_TIME = "Start" COLUMN_NAME_START_TIME = "Start"
COLUMN_NAME_TITLE = "Title" COLUMN_NAME_TITLE = "Title"
DBFS_FADE = -12 DBFS_FADE = -12
DBFS_SILENCE = -50 DBFS_SILENCE = -50
DEBUG_FUNCTIONS: List[Optional[str]] = []
DEBUG_MODULES: List[Optional[str]] = ['dbconfig']
DEFAULT_COLUMN_WIDTH = 200 DEFAULT_COLUMN_WIDTH = 200
DEFAULT_IMPORT_DIRECTORY = "/home/kae/Nextcloud/tmp" DEFAULT_IMPORT_DIRECTORY = "/home/kae/Nextcloud/tmp"
DEFAULT_OUTPUT_DIRECTORY = "/home/kae/music/Singles" DEFAULT_OUTPUT_DIRECTORY = "/home/kae/music/Singles"
@ -49,8 +39,8 @@ class Config(object):
FADE_STEPS = 20 FADE_STEPS = 20
FADE_TIME = 3000 FADE_TIME = 3000
INFO_TAB_TITLE_LENGTH = 15 INFO_TAB_TITLE_LENGTH = 15
LAST_PLAYED_TODAY_STRING = "Today" INFO_TAB_URL = "https://www.wikipedia.org/w/index.php?search=%s"
LOG_LEVEL_STDERR = logging.DEBUG LOG_LEVEL_STDERR = logging.INFO
LOG_LEVEL_SYSLOG = logging.DEBUG LOG_LEVEL_SYSLOG = logging.DEBUG
LOG_NAME = "musicmuster" LOG_NAME = "musicmuster"
MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD') MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD')
@ -58,7 +48,7 @@ class Config(object):
MAIL_SERVER = os.environ.get('MAIL_SERVER') or "woodlands.midnighthax.com" MAIL_SERVER = os.environ.get('MAIL_SERVER') or "woodlands.midnighthax.com"
MAIL_USERNAME = os.environ.get('MAIL_USERNAME') MAIL_USERNAME = os.environ.get('MAIL_USERNAME')
MAIL_USE_TLS = os.environ.get('MAIL_USE_TLS') is not None MAIL_USE_TLS = os.environ.get('MAIL_USE_TLS') is not None
MAX_INFO_TABS = 5 MAX_INFO_TABS = 3
MAX_MISSING_FILES_TO_REPORT = 10 MAX_MISSING_FILES_TO_REPORT = 10
MILLISECOND_SIGFIGS = 0 MILLISECOND_SIGFIGS = 0
MYSQL_CONNECT = os.environ.get('MYSQL_CONNECT') or "mysql+mysqldb://musicmuster:musicmuster@localhost/musicmuster_v2" # noqa E501 MYSQL_CONNECT = os.environ.get('MYSQL_CONNECT') or "mysql+mysqldb://musicmuster:musicmuster@localhost/musicmuster_v2" # noqa E501
@ -68,13 +58,12 @@ class Config(object):
IMPORT_DESTINATION = os.path.join(ROOT, "Singles") IMPORT_DESTINATION = os.path.join(ROOT, "Singles")
SCROLL_TOP_MARGIN = 3 SCROLL_TOP_MARGIN = 3
TESTMODE = True TESTMODE = True
TEXT_NO_TRACK_NO_NOTE = "[Section header]"
TOD_TIME_FORMAT = "%H:%M:%S" TOD_TIME_FORMAT = "%H:%M:%S"
TIMER_MS = 500 TIMER_MS = 500
TRACK_TIME_FORMAT = "%H:%M:%S" TRACK_TIME_FORMAT = "%H:%M:%S"
VOLUME_VLC_DEFAULT = 75 VOLUME_VLC_DEFAULT = 75
VOLUME_VLC_DROP3db = 65 VOLUME_VLC_DROP3db = 65
WEB_ZOOM_FACTOR = 1.2 WEB_ZOOM_FACTOR = 1.4
config = Config config = Config

View File

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

View File

@ -1,33 +1,24 @@
import os import os
import psutil import psutil
import shutil
import tempfile
from mutagen.flac import FLAC # type: ignore
from mutagen.mp3 import MP3 # type: ignore
from pydub import effects
from config import Config from config import Config
from datetime import datetime from datetime import datetime
from log import log
from pydub import AudioSegment from pydub import AudioSegment
from PyQt5.QtWidgets import QMessageBox from PyQt5.QtWidgets import QMessageBox
from tinytag import TinyTag # type: ignore from tinytag import TinyTag
from typing import Optional from typing import Dict, Optional, Union
# from typing import Dict, Optional, Union
from typing import Dict, Union
def ask_yes_no(title: str, question: str) -> bool: def ask_yes_no(title: str, question: str) -> bool:
"""Ask question; return True for yes, False for no""" """Ask question; return True for yes, False for no"""
button_reply = QMessageBox.question(None, title, question) button_reply: bool = QMessageBox.question(None, title, question)
return button_reply == QMessageBox.Yes return button_reply == QMessageBox.Yes
def fade_point( def fade_point(
audio_segment: AudioSegment, fade_threshold: float = 0.0, audio_segment: AudioSegment, fade_threshold: int = 0,
chunk_size: int = Config.AUDIO_SEGMENT_CHUNK_SIZE) -> int: chunk_size: int = Config.AUDIO_SEGMENT_CHUNK_SIZE) -> int:
""" """
Returns the millisecond/index of the point where the volume drops below Returns the millisecond/index of the point where the volume drops below
@ -40,8 +31,8 @@ def fade_point(
assert chunk_size > 0 # to avoid infinite loop assert chunk_size > 0 # to avoid infinite loop
segment_length: int = audio_segment.duration_seconds * 1000 # ms segment_length: int = audio_segment.duration_seconds * 1000 # ms
trim_ms = segment_length - chunk_size trim_ms: int = segment_length - chunk_size
max_vol = audio_segment.dBFS max_vol: int = audio_segment.dBFS
if fade_threshold == 0: if fade_threshold == 0:
fade_threshold = max_vol fade_threshold = max_vol
@ -55,48 +46,30 @@ def fade_point(
return int(trim_ms) return int(trim_ms)
def file_is_readable(path: str, check_colon: bool = True) -> bool:
"""
Returns True if passed path is readable, else False
vlc cannot read files with a colon in the path
"""
if os.access(path, os.R_OK):
if check_colon:
return ':' not in path
else:
return True
return False
def get_audio_segment(path: str) -> Optional[AudioSegment]: def get_audio_segment(path: str) -> Optional[AudioSegment]:
try: try:
if path.endswith('.mp3'): if path.endswith('.mp3'):
return AudioSegment.from_mp3(path) return AudioSegment.from_mp3(path)
elif path.endswith('.flac'): elif path.endswith('.flac'):
return AudioSegment.from_file(path, "flac") # type: ignore return AudioSegment.from_file(path, "flac")
except AttributeError: except AttributeError:
return None return None
return None
def get_tags(path: str) -> Dict[str, Union[str, int]]: def get_tags(path: str) -> Dict[str, Union[str, int]]:
""" """
Return a dictionary of title, artist, duration-in-milliseconds and path. Return a dictionary of title, artist, duration-in-milliseconds and path.
""" """
tag = TinyTag.get(path) tag: TinyTag = TinyTag.get(path)
return dict( d = dict(
title=tag.title, title=tag.title,
artist=tag.artist, artist=tag.artist,
bitrate=round(tag.bitrate),
duration=int(round(tag.duration, Config.MILLISECOND_SIGFIGS) * 1000), duration=int(round(tag.duration, Config.MILLISECOND_SIGFIGS) * 1000),
path=path path=path
) )
return d
def get_relative_date(past_date: datetime, reference_date: datetime = None) \ def get_relative_date(past_date: datetime, reference_date: datetime = None) \
@ -127,7 +100,7 @@ def get_relative_date(past_date: datetime, reference_date: datetime = None) \
weeks, days = divmod((reference_date.date() - past_date.date()).days, 7) weeks, days = divmod((reference_date.date() - past_date.date()).days, 7)
if weeks == days == 0: if weeks == days == 0:
# Same day so return time instead # Played today, so return time instead
return past_date.strftime("%H:%M") return past_date.strftime("%H:%M")
if weeks == 1: if weeks == 1:
weeks_str = "week" weeks_str = "week"
@ -191,64 +164,7 @@ def ms_to_mmss(ms: int, decimals: int = 0, negative: bool = False) -> str:
return f"{sign}{minutes:.0f}:{seconds:02.{decimals}f}" return f"{sign}{minutes:.0f}:{seconds:02.{decimals}f}"
def normalise_track(path): def open_in_audacity(path: str) -> Optional[bool]:
"""Normalise track"""
# Check type
ftype = os.path.splitext(path)[1][1:]
if ftype not in ['mp3', 'flac']:
log.info(
f"helpers.normalise_track({path}): "
f"File type {ftype} not implemented"
)
audio = get_audio_segment(path)
if not audio:
return
# Get current file gid, uid and permissions
stats = os.stat(path)
try:
# Copy original file
fd, temp_path = tempfile.mkstemp()
shutil.copyfile(path, temp_path)
except Exception as err:
log.debug(
f"helpers.normalise_track({path}): err1: {repr(err)}"
)
return
# Overwrite original file with normalised output
normalised = effects.normalize(audio)
try:
normalised.export(path, format=os.path.splitext(path)[1][1:])
# Fix up permssions and ownership
os.chown(path, stats.st_uid, stats.st_gid)
os.chmod(path, stats.st_mode)
# Copy tags
if ftype == 'flac':
tag_handler = FLAC
elif ftype == 'mp3':
tag_handler = MP3
else:
return
src = tag_handler(temp_path)
dst = tag_handler(path)
for tag in src:
dst[tag] = src[tag]
dst.save()
except Exception as err:
log.debug(
f"helpers.normalise_track({path}): err2: {repr(err)}"
)
# Restore original file
shutil.copyfile(path, temp_path)
finally:
if os.path.exists(temp_path):
os.remove(temp_path)
def open_in_audacity(path: str) -> bool:
""" """
Open passed file in Audacity Open passed file in Audacity
@ -296,31 +212,6 @@ def open_in_audacity(path: str) -> bool:
from_pipe, 'rt') as from_audacity: from_pipe, 'rt') as from_audacity:
do_command(f'Import2: Filename="{path}"') do_command(f'Import2: Filename="{path}"')
return True
def set_track_metadata(session, track):
"""Set/update track metadata in database"""
t = get_tags(track.path)
audio = get_audio_segment(track.path)
track.title = t['title']
track.artist = t['artist']
track.bitrate = t['bitrate']
if not audio:
return
track.duration = len(audio)
track.start_gap = leading_silence(audio)
track.fade_at = round(fade_point(audio) / 1000,
Config.MILLISECOND_SIGFIGS) * 1000
track.silence_at = round(trailing_silence(audio) / 1000,
Config.MILLISECOND_SIGFIGS) * 1000
track.mtime = os.path.getmtime(track.path)
session.commit()
def show_warning(title: str, msg: str) -> None: def show_warning(title: str, msg: str) -> None:
"""Display a warning to user""" """Display a warning to user"""

View File

@ -1,66 +0,0 @@
import urllib.parse
from datetime import datetime
from slugify import slugify # type: ignore
from typing import Dict, Optional
from PyQt5.QtCore import QUrl
from PyQt5.QtWebEngineWidgets import QWebEngineView
from PyQt5.QtWidgets import QTabWidget
from config import Config
class InfoTabs(QTabWidget):
"""
Class to manage info tabs
"""
def __init__(self, parent=None) -> None:
super().__init__(parent)
# Dictionary to record when tabs were last updated (so we can
# re-use the oldest one later)
self.last_update: Dict[QWebEngineView, datetime] = {}
def open_in_songfacts(self, title):
"""Search Songfacts for title"""
slug = slugify(title, replacements=([["'", ""]]))
url = f"https://www.songfacts.com/search/songs/{slug}"
self.open_tab(url, title)
def open_in_wikipedia(self, title):
"""Search Wikipedia for title"""
str = urllib.parse.quote_plus(title)
url = f"https://www.wikipedia.org/w/index.php?search={str}"
self.open_tab(url, title)
def open_tab(self, url: str, title: str) -> None:
"""
Open passed URL. Create new tab if we're below the maximum
number otherwise reuse oldest content tab.
"""
short_title = title[:Config.INFO_TAB_TITLE_LENGTH]
if self.count() < Config.MAX_INFO_TABS:
# Create a new tab
widget = QWebEngineView()
widget.setZoomFactor(Config.WEB_ZOOM_FACTOR)
tab_index = self.addTab(widget, short_title)
else:
# Reuse oldest widget
widget = min(
self.last_update, key=self.last_update.get # type: ignore
)
tab_index = self.indexOf(widget)
self.setTabText(tab_index, short_title)
widget.setUrl(QUrl(url))
self.last_update[widget] = datetime.now()
# Show newly updated tab
self.setCurrentIndex(tab_index)

View File

@ -11,7 +11,7 @@ from config import Config
class LevelTagFilter(logging.Filter): class LevelTagFilter(logging.Filter):
"""Add leveltag""" """Add leveltag"""
def filter(self, record: logging.LogRecord): def filter(self, record):
# Extract the first character of the level name # Extract the first character of the level name
record.leveltag = record.levelname[0] record.leveltag = record.levelname[0]
@ -20,20 +20,6 @@ class LevelTagFilter(logging.Filter):
return True return True
class DebugStdoutFilter(logging.Filter):
"""Filter debug messages sent to stdout"""
def filter(self, record: logging.LogRecord):
# Exceptions are logged at ERROR level
if record.levelno in [logging.DEBUG, logging.ERROR]:
return True
if record.module in Config.DEBUG_MODULES:
return True
if record.funcName in Config.DEBUG_FUNCTIONS:
return True
return False
log = logging.getLogger(Config.LOG_NAME) log = logging.getLogger(Config.LOG_NAME)
log.setLevel(logging.DEBUG) log.setLevel(logging.DEBUG)
@ -47,19 +33,13 @@ syslog.setLevel(Config.LOG_LEVEL_SYSLOG)
# Filter # Filter
local_filter = LevelTagFilter() local_filter = LevelTagFilter()
debug_filter = DebugStdoutFilter()
syslog.addFilter(local_filter) syslog.addFilter(local_filter)
stderr.addFilter(local_filter) stderr.addFilter(local_filter)
stderr.addFilter(debug_filter)
# create formatter and add it to the handlers # create formatter and add it to the handlers
stderr_fmt = logging.Formatter('[%(asctime)s] %(leveltag)s: %(message)s', stderr_fmt = logging.Formatter('[%(asctime)s] %(leveltag)s: %(message)s',
datefmt='%H:%M:%S') datefmt='%H:%M:%S')
syslog_fmt = logging.Formatter( syslog_fmt = logging.Formatter('[%(name)s] %(leveltag)s: %(message)s')
'[%(name)s] %(module)s.%(funcName)s - %(leveltag)s: %(message)s'
)
stderr.setFormatter(stderr_fmt) stderr.setFormatter(stderr_fmt)
syslog.setFormatter(syslog_fmt) syslog.setFormatter(syslog_fmt)
@ -77,3 +57,52 @@ def log_uncaught_exceptions(ex_cls, ex, tb):
sys.excepthook = log_uncaught_exceptions sys.excepthook = log_uncaught_exceptions
def DEBUG(msg: str, force_stderr: bool = False) -> None:
"""
Outupt a log message at level DEBUG. If force_stderr is True,
output this message to stderr regardless of default stderr level
setting.
"""
if force_stderr:
old_level = stderr.level
stderr.setLevel(logging.DEBUG)
log.debug(msg)
stderr.setLevel(old_level)
else:
log.debug(msg)
def EXCEPTION(msg: str) -> None:
log.exception(msg, exc_info=True, stack_info=True)
def ERROR(msg: str) -> None:
log.error(msg)
def INFO(msg: str) -> None:
log.info(msg)
if __name__ == "__main__":
DEBUG("hi debug")
ERROR("hi error")
INFO("hi info")
EXCEPTION("hi exception")
def f():
return g()
def g():
return h()
def h():
return i()
def i():
return 1 / 0
f()

View File

@ -1,66 +1,73 @@
#!/usr/bin/python3 #!/usr/bin/python3
#
import os.path import os.path
import re import re
#
from dbconfig import Session from dbconfig import Session
#
from datetime import datetime from datetime import datetime
from typing import List, Optional from typing import List, Optional
#
# from pydub import AudioSegment from pydub import AudioSegment
from sqlalchemy.ext.associationproxy import association_proxy from sqlalchemy.ext.associationproxy import association_proxy
# from sqlalchemy.ext.declarative import declarative_base, DeclarativeMeta from sqlalchemy.ext.declarative import declarative_base, DeclarativeMeta
from sqlalchemy import ( from sqlalchemy import (
Boolean, Boolean,
Column, Column,
DateTime, DateTime,
delete,
Float, Float,
ForeignKey, ForeignKey,
func, func,
Integer, Integer,
select,
String, String,
UniqueConstraint, UniqueConstraint,
) )
# from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import ( from sqlalchemy.orm import (
backref, backref,
declarative_base,
relationship, relationship,
RelationshipProperty RelationshipProperty
) )
from sqlalchemy.orm.collections import attribute_mapped_collection from sqlalchemy.orm.collections import attribute_mapped_collection
from sqlalchemy.orm.exc import ( from sqlalchemy.orm.exc import MultipleResultsFound, NoResultFound
# MultipleResultsFound,
NoResultFound
)
#
from config import Config from config import Config
from helpers import ( from helpers import (
fade_point, fade_point,
get_audio_segment, get_audio_segment,
get_tags,
leading_silence, leading_silence,
trailing_silence, trailing_silence,
) )
from log import log from log import DEBUG, ERROR
#
Base = declarative_base() Base: DeclarativeMeta = declarative_base()
# Database classes # Database classes
class NoteColours(Base): class NoteColours(Base):
__tablename__ = 'notecolours' __tablename__ = 'notecolours'
id = Column(Integer, primary_key=True, autoincrement=True) id: int = Column(Integer, primary_key=True, autoincrement=True)
substring = Column(String(256), index=False) substring: str = Column(String(256), index=False)
colour = Column(String(21), index=False) colour: str = Column(String(21), index=False)
enabled = Column(Boolean, default=True, index=True) enabled: bool = Column(Boolean, default=True, index=True)
is_regex = Column(Boolean, default=False, index=False) is_regex: bool = Column(Boolean, default=False, index=False)
is_casesensitive = Column(Boolean, default=False, index=False) is_casesensitive: bool = Column(Boolean, default=False, index=False)
order = Column(Integer, index=True) order: int = Column(Integer, index=True)
def __init__(
self, session: Session, substring: str, colour: str,
enabled: bool = True, is_regex: bool = False,
is_casesensitive: bool = False, order: int = 0) -> None:
self.substring = substring
self.colour = colour
self.enabled = enabled
self.is_regex = is_regex
self.is_casesensitive = is_casesensitive
self.order = order
session.add(self)
session.flush()
def __repr__(self) -> str: def __repr__(self) -> str:
return ( return (
@ -68,34 +75,19 @@ class NoteColours(Base):
f"colour={self.colour}>" f"colour={self.colour}>"
) )
# def __init__( @classmethod
# self, session: Session, substring: str, colour: str, def get_all(cls, session: Session) -> Optional[List["NoteColours"]]:
# enabled: bool = True, is_regex: bool = False, """Return all records"""
# is_casesensitive: bool = False, order: int = 0) -> None:
# self.substring = substring return session.query(cls).all()
# self.colour = colour
# self.enabled = enabled @classmethod
# self.is_regex = is_regex def get_by_id(cls, session: Session, note_id: int) -> \
# self.is_casesensitive = is_casesensitive Optional["NoteColours"]:
# self.order = order """Return record identified by id, or None if not found"""
#
# session.add(self) return session.query(NoteColours).filter(
# session.flush() NoteColours.id == note_id).first()
#
# @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 @staticmethod
def get_colour(session: Session, text: str) -> Optional[str]: def get_colour(session: Session, text: str) -> Optional[str]:
@ -103,14 +95,12 @@ class NoteColours(Base):
Parse text and return colour string if matched, else None Parse text and return colour string if matched, else None
""" """
if not text: for rec in (
return None session.query(NoteColours)
for rec in session.execute(
select(NoteColours)
.filter(NoteColours.enabled.is_(True)) .filter(NoteColours.enabled.is_(True))
.order_by(NoteColours.order) .order_by(NoteColours.order)
).scalars().all(): .all()
):
if rec.is_regex: if rec.is_regex:
flags = re.UNICODE flags = re.UNICODE
if not rec.is_casesensitive: if not rec.is_casesensitive:
@ -129,39 +119,118 @@ class NoteColours(Base):
return None return None
class Notes(Base):
__tablename__ = 'notes'
id: int = Column(Integer, primary_key=True, autoincrement=True)
playlist_id: int = Column(Integer, ForeignKey('playlists.id'))
playlist: RelationshipProperty = relationship(
"Playlists", back_populates="notes", lazy="joined")
row: int = Column(Integer, nullable=False)
note: str = Column(String(256), index=False)
def __init__(self, session: Session, playlist_id: int,
row: int, text: str) -> None:
"""Create note"""
DEBUG(f"Notes.__init__({playlist_id=}, {row=}, {text=})")
self.playlist_id = playlist_id
self.row = row
self.note = text
session.add(self)
session.flush()
def __repr__(self) -> str:
return (
f"<Note(id={self.id}, row={self.row}, note={self.note}>"
)
def delete_note(self, session: Session) -> None:
"""Delete note"""
DEBUG(f"delete_note({self.id=}")
session.query(Notes).filter_by(id=self.id).delete()
session.flush()
@staticmethod
def max_used_row(session: Session, playlist_id: int) -> Optional[int]:
"""
Return maximum notes row for passed playlist ID or None if not notes
"""
last_row = session.query(func.max(Notes.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:
return last_row[0]
else:
return None
def move_row(self, session: Session, row: int, to_playlist_id: int) \
-> None:
"""
Move note to another playlist
"""
self.row = row
self.playlist_id = to_playlist_id
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(
self, session: Session, row: int,
text: Optional[str] = None) -> None:
"""
Update note details. If text=None, don't change text.
"""
DEBUG(f"Notes.update_note({self.id=}, {row=}, {text=})")
self.row = row
if text:
self.note = text
session.flush()
class Playdates(Base): class Playdates(Base):
__tablename__ = 'playdates' __tablename__ = 'playdates'
id: int = Column(Integer, primary_key=True, autoincrement=True) id: int = Column(Integer, primary_key=True, autoincrement=True)
lastplayed = Column(DateTime, index=True, default=None) lastplayed: datetime = Column(DateTime, index=True, default=None)
track_id = Column(Integer, ForeignKey('tracks.id')) track_id: int = Column(Integer, ForeignKey('tracks.id'))
track = relationship("Tracks", back_populates="playdates") track: RelationshipProperty = relationship(
"Tracks", back_populates="playdates", lazy="joined")
def __repr__(self) -> str:
return (
f"<Playdates(id={self.id}, track_id={self.track_id} "
f"lastplayed={self.lastplayed}>"
)
def __init__(self, session: Session, track_id: int) -> None: def __init__(self, session: Session, track_id: int) -> None:
"""Record that track was played""" """Record that track was played"""
DEBUG(f"add_playdate({track_id=})")
self.lastplayed = datetime.now() self.lastplayed = datetime.now()
self.track_id = track_id self.track_id = track_id
session.add(self) session.add(self)
session.commit() session.flush()
@staticmethod @staticmethod
def last_played(session: Session, track_id: int) -> Optional[datetime]: def last_played(session: Session, track_id: int) -> Optional[datetime]:
"""Return datetime track last played or None""" """Return datetime track last played or None"""
last_played = session.execute( last_played: Optional[Playdates] = session.query(
select(Playdates.lastplayed) Playdates.lastplayed).filter(
.where(Playdates.track_id == track_id) (Playdates.track_id == track_id)
.order_by(Playdates.lastplayed.desc()) ).order_by(Playdates.lastplayed.desc()).first()
.limit(1)
).first()
if last_played: if last_played:
return last_played[0] return last_played[0]
else: else:
@ -171,25 +240,18 @@ class Playdates(Base):
def played_after(session: Session, since: datetime) -> List["Playdates"]: def played_after(session: Session, since: datetime) -> List["Playdates"]:
"""Return a list of Playdates objects since passed time""" """Return a list of Playdates objects since passed time"""
return ( return session.query(Playdates).filter(
session.execute( Playdates.lastplayed >= since).all()
select(Playdates)
.where(Playdates.lastplayed >= since)
.order_by(Playdates.lastplayed)
)
.scalars()
.all()
)
# @staticmethod @staticmethod
# def remove_track(session: Session, track_id: int) -> None: def remove_track(session: Session, track_id: int) -> None:
# """ """
# Remove all records of track_id Remove all records of track_id
# """ """
#
# session.query(Playdates).filter( session.query(Playdates).filter(
# Playdates.track_id == track_id).delete() Playdates.track_id == track_id).delete()
# session.flush() session.flush()
class Playlists(Base): class Playlists(Base):
@ -201,276 +263,194 @@ class Playlists(Base):
id: int = Column(Integer, primary_key=True, autoincrement=True) id: int = Column(Integer, primary_key=True, autoincrement=True)
name: str = Column(String(32), nullable=False, unique=True) name: str = Column(String(32), nullable=False, unique=True)
last_used = Column(DateTime, default=None, nullable=True) last_used: datetime = Column(DateTime, default=None, nullable=True)
loaded: bool = Column(Boolean, default=True, nullable=False) loaded: bool = Column(Boolean, default=True, nullable=False)
rows = relationship( notes = relationship(
"PlaylistRows", "Notes", order_by="Notes.row",
back_populates="playlist", back_populates="playlist", lazy="joined"
cascade="all, delete-orphan",
order_by="PlaylistRows.row_number"
) )
def __repr__(self) -> str: tracks = association_proxy('playlist_tracks', 'tracks')
return f"<Playlists(id={self.id}, name={self.name}>" row = association_proxy('playlist_tracks', 'row')
def __init__(self, session: Session, name: str) -> None: def __init__(self, session: Session, name: str) -> None:
self.name = name self.name = name
session.add(self) session.add(self)
session.commit() session.flush()
# def add_track( def __repr__(self) -> str:
# self, session: Session, track_id: int, return f"<Playlists(id={self.id}, name={self.name}>"
# row: Optional[int] = None) -> None:
# """ def add_track(
# Add track to playlist at given row. self, session: Session, track_id: int,
# If row=None, add to end of playlist row: Optional[int] = None) -> None:
# """ """
# Add track to playlist at given row.
# if row is None: If row=None, add to end of playlist
# row = self.next_free_row(session, self.id) """
#
# xPlaylistTracks(session, self.id, track_id, row) if row is None:
row = self.next_free_row(session, self.id)
PlaylistTracks(session, self.id, track_id, row)
def close(self, session: Session) -> None: def close(self, session: Session) -> None:
"""Mark playlist as unloaded""" """Record playlist as no longer loaded"""
self.loaded = False self.loaded = False
session.add(self)
session.flush()
@classmethod @classmethod
def get_all(cls, session: Session) -> List["Playlists"]: def get_all(cls, session: Session) -> List["Playlists"]:
"""Returns a list of all playlists ordered by last use""" """Returns a list of all playlists ordered by last use"""
return ( return (
session.execute( session.query(cls).order_by(cls.last_used.desc())
select(cls) ).all()
.order_by(cls.loaded.desc(), cls.last_used.desc())
) @classmethod
.scalars() def get_by_id(cls, session: Session, playlist_id: int) -> "Playlists":
.all() return (session.query(cls).filter(cls.id == playlist_id)).one()
)
@classmethod @classmethod
def get_closed(cls, session: Session) -> List["Playlists"]: def get_closed(cls, session: Session) -> List["Playlists"]:
"""Returns a list of all closed playlists ordered by last use""" """Returns a list of all closed playlists ordered by last use"""
return ( return (
session.execute( session.query(cls)
select(cls)
.filter(cls.loaded.is_(False)) .filter(cls.loaded.is_(False))
.order_by(cls.last_used.desc()) .order_by(cls.last_used.desc())
) ).all()
.scalars()
.all()
)
@classmethod @classmethod
def get_open(cls, session: Session) -> List[Optional["Playlists"]]: def get_open(cls, session: Session) -> List["Playlists"]:
""" """
Return a list of playlists marked "loaded", ordered by loaded date. Return a list of playlists marked "loaded", ordered by loaded date.
""" """
return ( return (
session.execute( session.query(cls)
select(cls) .filter(cls.loaded.is_(True))
.where(cls.loaded.is_(True))
.order_by(cls.last_used.desc()) .order_by(cls.last_used.desc())
) ).all()
.scalars()
.all()
)
def mark_open(self, session: Session) -> None: def mark_open(self, session: Session) -> None:
"""Mark playlist as loaded and used now""" """Mark playlist as loaded and used now"""
self.loaded = True self.loaded = True
self.last_used = datetime.now() self.last_used = datetime.now()
session.flush()
# def remove_track(self, session: Session, row: int) -> None: @staticmethod
# log.debug(f"Playlist.remove_track({self.id=}, {row=})") def next_free_row(session: Session, playlist_id: int) -> int:
# """Return next free row for this playlist"""
# # Refresh self first (this is necessary when calling
# remove_track max_notes_row = Notes.max_used_row(session, playlist_id)
# # multiple times before session.commit()) max_tracks_row = PlaylistTracks.max_used_row(session, playlist_id)
# session.refresh(self)
# # Get tracks collection for this playlist if max_notes_row is not None and max_tracks_row is not None:
# # Tracks are a dictionary of tracks keyed on row return max(max_notes_row, max_tracks_row) + 1
# # number. Remove the relevant row.
# del self.tracks[row] if max_notes_row is None and max_tracks_row is None:
# # Save the new tracks collection return 0
# session.flush()
# if max_notes_row is None:
return max_tracks_row + 1
else:
return max_notes_row + 1
def remove_all_tracks(self, session: Session) -> None:
"""
Remove all tracks from this playlist
"""
self.tracks = {}
session.flush()
def remove_track(self, session: Session, row: int) -> None:
DEBUG(f"Playlist.remove_track({self.id=}, {row=})")
# Refresh self first (this is necessary when calling remove_track
# multiple times before session.commit())
session.refresh(self)
# Get tracks collection for this playlist
# Tracks are a dictionary of tracks keyed on row
# number. Remove the relevant row.
del self.tracks[row]
# Save the new tracks collection
session.flush()
class PlaylistRows(Base): class PlaylistTracks(Base):
__tablename__ = 'playlist_rows' __tablename__ = 'playlist_tracks'
id = Column(Integer, primary_key=True, autoincrement=True) id: int = Column(Integer, primary_key=True, autoincrement=True)
row_number = Column(Integer, nullable=False) playlist_id: int = Column(Integer, ForeignKey('playlists.id'),
note = Column(String(2048), index=False) primary_key=True)
playlist_id = Column(Integer, ForeignKey('playlists.id'), nullable=False) track_id: int = Column(Integer, ForeignKey('tracks.id'), primary_key=True)
playlist = relationship(Playlists, back_populates="rows") row: int = Column(Integer, nullable=False)
track_id = Column(Integer, ForeignKey('tracks.id'), nullable=True) tracks: RelationshipProperty = relationship("Tracks")
track = relationship("Tracks", back_populates="playlistrows") playlist: RelationshipProperty = relationship(
played = Column(Boolean, nullable=False, index=False, default=False) Playlists,
backref=backref(
def __repr__(self) -> str: "playlist_tracks",
return ( collection_class=attribute_mapped_collection("row"),
f"<PlaylistRow(id={self.id}, playlist_id={self.playlist_id}, " lazy="joined",
f"track_id={self.track_id}, " cascade="all, delete-orphan"
f"note={self.note} row_number={self.row_number}>" )
)
# Ensure row numbers are unique within each playlist
__table_args__ = (UniqueConstraint
('row', 'playlist_id', name="uniquerow"),
) )
def __init__( def __init__(
self, session: Session, playlist_id: int, track_id: int, self, session: Session, playlist_id: int, track_id: int,
row_number: int) -> None: row: int) -> None:
"""Create PlaylistRows object""" DEBUG(f"PlaylistTracks.__init__({playlist_id=}, {track_id=}, {row=})")
self.playlist_id = playlist_id self.playlist_id = playlist_id
self.track_id = track_id self.track_id = track_id
self.row_number = row_number self.row = row
session.add(self) session.add(self)
session.flush() session.flush()
@staticmethod @staticmethod
def delete_higher_rows(session: Session, playlist_id: int, row: int) \ def max_used_row(session: Session, playlist_id: int) -> Optional[int]:
-> None:
""" """
Delete rows in given playlist that have a higher row number Return highest track row number used or None if there are no
than 'row' tracks
""" """
# Log the rows to be deleted last_row = session.query(
rows_to_go = session.execute( func.max(PlaylistTracks.row)
select(PlaylistRows) ).filter_by(playlist_id=playlist_id).first()
.where(PlaylistRows.playlist_id == playlist_id, # if there are no rows, the above returns (None, ) which is True
PlaylistRows.row_number > row) if last_row and last_row[0] is not None:
).scalars().all() return last_row[0]
if not rows_to_go: else:
return return None
for row in rows_to_go:
log.debug(f"Should delete: {row}")
# If needed later:
# session.delete(row)
rows_to_go = session.execute(
select(PlaylistRows)
.where(PlaylistRows.playlist_id == playlist_id,
PlaylistRows.row_number > row)
).scalars().all()
@staticmethod @staticmethod
def delete_rows(session: Session, ids: List[int]) -> None: def move_row(session: Session, from_row: int, from_playlist_id: int,
""" to_row: int, to_playlist_id: int) -> None:
Delete passed ids """Move row to another playlist"""
"""
session.execute( session.query(PlaylistTracks).filter(
delete(PlaylistRows) PlaylistTracks.playlist_id == from_playlist_id,
.where(PlaylistRows.id.in_(ids)) PlaylistTracks.row == from_row).update(
) {'playlist_id': to_playlist_id, 'row': to_row}, False)
# Delete won't take effect until commit()
session.commit()
@staticmethod
def fixup_rownumbers(session: Session, playlist_id: int) -> None:
"""
Ensure the row numbers for passed playlist have no gaps
"""
plrs = session.execute(
select(PlaylistRows)
.where(PlaylistRows.playlist_id == playlist_id)
.order_by(PlaylistRows.row_number)
).scalars().all()
for i, plr in enumerate(plrs):
plr.row_number = i
# Ensure new row numbers are available to the caller
session.commit()
@classmethod
def get_played_rows(cls, session: Session,
playlist_id: int) -> List[int]:
"""
For passed playlist, return a list of rows that
have been played.
"""
plrs = session.execute(
select(cls)
.where(
cls.playlist_id == playlist_id,
cls.played.is_(True)
)
.order_by(cls.row_number)
).scalars().all()
return plrs
@classmethod
def get_rows_with_tracks(cls, session: Session,
playlist_id: int) -> List[int]:
"""
For passed playlist, return a list of rows that
contain tracks
"""
plrs = session.execute(
select(cls)
.where(
cls.playlist_id == playlist_id,
cls.track_id.is_not(None)
)
.order_by(cls.row_number)
).scalars().all()
return plrs
@staticmethod
def get_last_used_row(session: Session, playlist_id: int) -> Optional[int]:
"""Return the last used row for playlist, or None if no rows"""
return session.execute(
select(func.max(PlaylistRows.row_number))
.where(PlaylistRows.playlist_id == playlist_id)
).scalar_one()
@classmethod
def get_unplayed_rows(cls, session: Session,
playlist_id: int) -> List[int]:
"""
For passed playlist, return a list of track rows that
have not been played.
"""
plrs = session.execute(
select(cls)
.where(
cls.playlist_id == playlist_id,
cls.track_id.is_not(None),
cls.played.is_(False)
)
.order_by(cls.row_number)
).scalars().all()
return plrs
class Settings(Base): class Settings(Base):
"""Manage settings"""
__tablename__ = 'settings' __tablename__ = 'settings'
id: int = Column(Integer, primary_key=True, autoincrement=True) id: int = Column(Integer, primary_key=True, autoincrement=True)
name: str = Column(String(64), nullable=False, unique=True) name: str = Column(String(32), nullable=False, unique=True)
f_datetime = Column(DateTime, default=None, nullable=True) f_datetime: datetime = Column(DateTime, default=None, nullable=True)
f_int: int = Column(Integer, default=None, nullable=True) f_int: int = Column(Integer, default=None, nullable=True)
f_string = Column(String(128), default=None, nullable=True) f_string: str = Column(String(128), default=None, nullable=True)
def __repr__(self) -> str:
value = self.f_datetime or self.f_int or self.f_string
return f"<Settings(id={self.id}, name={self.name}, {value=}>"
@classmethod @classmethod
def get_int_settings(cls, session: Session, name: str) -> "Settings": def get_int_settings(cls, session: Session, name: str) -> "Settings":
@ -479,20 +459,17 @@ class Settings(Base):
int_setting: Settings int_setting: Settings
try: try:
int_setting = session.execute( int_setting = session.query(cls).filter(
select(cls) cls.name == name).one()
.where(cls.name == name)
).scalar_one()
except NoResultFound: except NoResultFound:
int_setting = Settings() int_setting = Settings()
int_setting.name = name int_setting.name = name
int_setting.f_int = None int_setting.f_int = None
session.add(int_setting) session.add(int_setting)
session.flush()
return int_setting return int_setting
def update(self, session: Session, data: "Settings"): def update(self, session: Session, data):
for key, value in data.items(): for key, value in data.items():
assert hasattr(self, key) assert hasattr(self, key)
setattr(self, key, value) setattr(self, key, value)
@ -503,24 +480,22 @@ class Tracks(Base):
__tablename__ = 'tracks' __tablename__ = 'tracks'
id: int = Column(Integer, primary_key=True, autoincrement=True) id: int = Column(Integer, primary_key=True, autoincrement=True)
title = Column(String(256), index=True) title: str = Column(String(256), index=True)
artist = Column(String(256), index=True) artist: str = Column(String(256), index=True)
duration = Column(Integer, index=True) duration: int = Column(Integer, index=True)
start_gap = Column(Integer, index=False) start_gap: int = Column(Integer, index=False)
fade_at = Column(Integer, index=False) fade_at: int = Column(Integer, index=False)
silence_at = Column(Integer, index=False) silence_at: int = Column(Integer, index=False)
path = Column(String(2048), index=False, nullable=False, unique=True) path: str = Column(String(2048), index=False, nullable=False)
mtime = Column(Float, index=True) mtime: float = Column(Float, index=True)
bitrate = Column(Integer, nullable=True, default=None) lastplayed: datetime = Column(DateTime, index=True, default=None)
playlistrows = relationship("PlaylistRows", back_populates="track") playlists: RelationshipProperty = relationship("PlaylistTracks",
playlists = association_proxy("playlistrows", "playlist") back_populates="tracks",
playdates = relationship("Playdates", back_populates="track") lazy="joined")
playdates: RelationshipProperty = relationship("Playdates",
def __repr__(self) -> str: back_populates="track"
return ( "",
f"<Track(id={self.id}, title={self.title}, " lazy="joined")
f"artist={self.artist}, path={self.path}>"
)
def __init__( def __init__(
self, self,
@ -546,53 +521,145 @@ class Tracks(Base):
self.lastplayed = lastplayed self.lastplayed = lastplayed
session.add(self) session.add(self)
session.commit() session.flush()
def __repr__(self) -> str:
return (
f"<Track(id={self.id}, title={self.title}, "
f"artist={self.artist}, path={self.path}>"
)
@staticmethod
def get_all_paths(session) -> List[str]:
"""Return a list of paths of all tracks"""
return [a[0] for a in session.query(Tracks.path).all()]
@classmethod @classmethod
def get_all(cls, session) -> List["Tracks"]: def get_all_tracks(cls, session: Session) -> List["Tracks"]:
"""Return a list of all tracks""" """Return a list of all tracks"""
return session.execute(select(cls)).scalars().all() return session.query(cls).all()
@classmethod @classmethod
def get_by_path(cls, session: Session, path: str) -> "Tracks": def get_or_create(cls, session: Session, path: str) -> "Tracks":
""" """
Return track with passed path, or None. If a track with path exists, return it;
else created new track and return it
""" """
DEBUG(f"Tracks.get_or_create({path=})")
try: try:
return ( track = session.query(cls).filter(cls.path == path).one()
session.execute(
select(Tracks)
.where(Tracks.path == path)
).scalar_one()
)
except NoResultFound: except NoResultFound:
track = Tracks(session, path)
return track
@classmethod
def get_by_filename(cls, session: Session, filename: str) \
-> Optional["Tracks"]:
"""
Return track if one and only one track in database has passed
filename (ie, basename of path). Return None if zero or more
than one track matches.
"""
DEBUG(f"Tracks.get_track_from_filename({filename=})")
try:
track = session.query(Tracks).filter(Tracks.path.ilike(
f'%{os.path.sep}{filename}')).one()
return track
except (NoResultFound, MultipleResultsFound):
return None return None
@classmethod
def get_by_path(cls, session: Session, path: str) -> List["Tracks"]:
"""
Return track with passee path, or None.
"""
DEBUG(f"Tracks.get_track_from_path({path=})")
return session.query(Tracks).filter(Tracks.path == path).first()
@classmethod
def get_by_id(cls, session: Session, track_id: int) -> Optional["Tracks"]:
"""Return track or None"""
try:
DEBUG(f"Tracks.get_track(track_id={track_id})")
track = session.query(Tracks).filter(Tracks.id == track_id).one()
return track
except NoResultFound:
ERROR(f"get_track({track_id}): not found")
return None
def rescan(self, session: Session) -> None:
"""
Update audio metadata for passed track.
"""
audio: AudioSegment = get_audio_segment(self.path)
self.duration = len(audio)
self.fade_at = round(fade_point(audio) / 1000,
Config.MILLISECOND_SIGFIGS) * 1000
self.mtime = os.path.getmtime(self.path)
self.silence_at = round(trailing_silence(audio) / 1000,
Config.MILLISECOND_SIGFIGS) * 1000
self.start_gap = leading_silence(audio)
session.add(self)
session.flush()
@staticmethod
def remove_by_path(session: Session, path: str) -> None:
"""Remove track with passed path from database"""
DEBUG(f"Tracks.remove_path({path=})")
try:
session.query(Tracks).filter(Tracks.path == path).delete()
session.flush()
except IntegrityError as exception:
ERROR(f"Can't remove track with {path=} ({exception=})")
@classmethod @classmethod
def search_artists(cls, session: Session, text: str) -> List["Tracks"]: def search_artists(cls, session: Session, text: str) -> List["Tracks"]:
"""Search case-insenstively for artists containing str"""
return ( return (
session.execute( session.query(cls)
select(cls) .filter(cls.artist.ilike(f"%{text}%"))
.where(cls.artist.ilike(f"%{text}%"))
.order_by(cls.title) .order_by(cls.title)
) ).all()
.scalars()
.all()
)
@classmethod @classmethod
def search_titles(cls, session: Session, text: str) -> List["Tracks"]: def search_titles(cls, session: Session, text: str) -> List["Tracks"]:
"""Search case-insenstively for titles containing str"""
return ( return (
session.execute( session.query(cls)
select(cls) .filter(cls.title.ilike(f"%{text}%"))
.where(cls.title.ilike(f"%{text}%"))
.order_by(cls.title) .order_by(cls.title)
) ).all()
.scalars()
.all() @staticmethod
) def update_lastplayed(session: Session, track_id: int) -> None:
"""Update the last_played field to current datetime"""
rec = session.query(Tracks).get(track_id)
rec.lastplayed = datetime.now()
session.add(rec)
session.flush()
def update_artist(self, session: Session, artist: str) -> None:
self.artist = artist
session.add(self)
session.flush()
def update_title(self, session: Session, title: str) -> None:
self.title = title
session.add(self)
session.flush()
def update_path(self, session, newpath: str) -> None:
self.path = newpath
session.commit()

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,54 +0,0 @@
#!/usr/bin/env python
#
# Script to manage renaming existing files in given directory and
# propagating that change to database. Typical usage: renaming files
# from 'title.mp3' to title - artist.mp3'
#
# Actions:
#
# - record all filenames and inode numbers
# - external: rename the files
# - update records with new filenames for each inode number
# - update external database with new paths
import os
import sqlite3
PHASE = 2
# Check file of same name exists in parent directory
source_dir = '/home/kae/tmp/Singles' # os.getcwd()
db = "/home/kae/tmp/singles.sqlite"
def main():
with sqlite3.connect(db) as connection:
cursor = connection.cursor()
if PHASE == 1:
cursor.execute(
"CREATE TABLE IF NOT EXISTS mp3s "
"(inode INTEGER, oldname TEXT, newname TEXT)"
)
for fname in os.listdir(source_dir):
fullpath = os.path.join(source_dir, fname)
inode = os.stat(fullpath).st_ino
sql = f'INSERT INTO mp3s VALUES ({inode}, "{fname}", "")'
cursor.execute(sql)
if PHASE == 2:
for fname in os.listdir(source_dir):
fullpath = os.path.join(source_dir, fname)
inode = os.stat(fullpath).st_ino
sql = (
f'UPDATE mp3s SET newname = "{fname}" WHERE inode={inode}'
)
try:
cursor.execute(sql)
except sqlite3.OperationalError:
print(f"Error with {inode} -> {fname}")
cursor.close()
main()

View File

@ -1,283 +0,0 @@
#!/usr/bin/env python
#
# Script to replace existing files in parent directory. Typical usage:
# the current directory contains a "better" version of the file than the
# parent (eg, bettet bitrate).
import glob
import os
import pydymenu # type: ignore
import shutil
import sys
from helpers import (
fade_point,
get_audio_segment,
get_tags,
leading_silence,
trailing_silence,
set_track_metadata,
)
from models import Tracks
from dbconfig import Session
from thefuzz import process # type: ignore
from sqlalchemy.exc import IntegrityError
from typing import List
# ###################### SETTINGS #########################
process_name_and_tags_matches = True
process_tag_matches = True
do_processing = True
process_no_matches = True
source_dir = '/home/kae/music/Singles/tmp'
parent_dir = os.path.dirname(source_dir)
# #########################################################
def insensitive_glob(pattern):
"""Helper for case insensitive glob.glob()"""
def either(c):
return '[%s%s]' % (c.lower(), c.upper()) if c.isalpha() else c
return glob.glob(''.join(map(either, pattern)))
name_and_tags: List[str] = []
tags_not_name: List[str] = []
multiple_similar: List[str] = []
no_match: List[str] = []
possibles: List[str] = []
no_match: int = 0
def main():
global no_match
# We only want to run this against the production database because
# we will affect files in the common pool of tracks used by all
# databases
if 'musicmuster_prod' not in os.environ.get('MM_DB'):
response = input("Not on production database - c to continue: ")
if response != "c":
sys.exit(0)
# Sanity check
assert source_dir != parent_dir
# Scan parent directory
with Session() as session:
all_tracks = Tracks.get_all(session)
parent_tracks = [a for a in all_tracks if parent_dir in a.path]
parent_fnames = [os.path.basename(a.path) for a in parent_tracks]
# Create a dictionary of parent paths with their titles and
# artists
parents = {}
for t in parent_tracks:
parents[t.path] = {"title": t.title, "artist": t.artist}
titles_to_path = {}
artists_to_path = {}
for k, v in parents.items():
try:
titles_to_path[v['title'].lower()] = k
artists_to_path[v['artist'].lower()] = k
except AttributeError:
continue
for new_fname in os.listdir(source_dir):
new_path = os.path.join(source_dir, new_fname)
new_tags = get_tags(new_path)
new_title = new_tags['title']
new_artist = new_tags['artist']
bitrate = new_tags['bitrate']
# If same filename exists in parent direcory, check tags
parent_path = os.path.join(parent_dir, new_fname)
if os.path.exists(parent_path):
parent_tags = get_tags(parent_path)
parent_title = parent_tags['title']
parent_artist = parent_tags['artist']
if (
(str(parent_title).lower() == str(new_title).lower()) and
(str(parent_artist).lower() == str(new_artist).lower())
):
name_and_tags.append(
f" {new_fname=}, {parent_title}{new_title}, "
f" {parent_artist}{new_artist}"
)
if process_name_and_tags_matches:
process_track(new_path, parent_path, new_title,
new_artist, bitrate)
continue
# Check for matching tags although filename is different
if new_title.lower() in titles_to_path:
possible_path = titles_to_path[new_title.lower()]
if parents[possible_path]['artist'].lower() == new_artist.lower():
# print(
# f"title={new_title}, artist={new_artist}:\n"
# f" {new_path} → {parent_path}"
# )
tags_not_name.append(
f"title={new_title}, artist={new_artist}:\n"
f" {new_path}{parent_path}"
)
if process_tag_matches:
process_track(new_path, possible_path, new_title,
new_artist, bitrate)
continue
else:
no_match += 1
else:
no_match += 1
# Try to find a near match
# stem = new_fname.split(".")[0]
# matches = insensitive_glob(os.path.join(parent_dir, stem) + '*')
# match_count = len(matches)
# if match_count == 0:
if process_no_matches:
prompt = f"\n file={new_fname}\n title={new_title}\n artist={new_artist}: "
# Use fzf to search
choice = pydymenu.fzf(parent_fnames, prompt)
if choice:
old_file = os.path.join(parent_dir, choice[0])
oldtags = get_tags(old_file)
old_title = oldtags['title']
old_artist = oldtags['artist']
print()
print(f" File name will change {choice[0]}")
print(f"{new_fname}")
print()
print(f" Title tag will change {old_title}")
print(f"{new_title}")
print()
print(f" Artist tag will change {old_artist}")
print(f"{new_artist}")
print()
data = input("Go ahead (y to accept)? ")
if data == "y":
process_track(new_path, old_file, new_title, new_artist, bitrate)
continue
if data == "q":
sys.exit(0)
else:
continue
# else:
# no_match.append(f"{new_fname}, {new_title=}, {new_artist=}")
# continue
# if match_count > 1:
# multiple_similar.append(new_fname + "\n " + "\n ".join(matches))
# if match_count <= 26 and process_multiple_matches:
# print(f"\n file={new_fname}\n title={new_title}\n artist={new_artist}\n")
# d = {}
# while True:
# for i, match in enumerate(matches):
# d[i] = match
# for k, v in d.items():
# print(f"{k}: {v}")
# data = input("pick one, return to quit: ")
# if data == "":
# break
# try:
# key = int(data)
# except ValueError:
# continue
# if key in d:
# dst = d[key]
# process_track(new_path, dst, new_title, new_artist, bitrate)
# break
# else:
# continue
# continue # from break after testing for "" in data
# # One match, check tags
# sim_name = matches[0]
# p = get_tags(sim_name)
# parent_title = p['title']
# parent_artist = p['artist']
# if (
# (str(parent_title).lower() != str(new_title).lower()) or
# (str(parent_artist).lower() != str(new_artist).lower())
# ):
# possibles.append(
# f"File: {os.path.basename(sim_name)} → {new_fname}"
# f"\n {parent_title} → {new_title}\n {parent_artist} → {new_artist}"
# )
# process_track(new_path, sim_name, new_title, new_artist, bitrate)
# continue
# tags_not_name.append(f"Rename {os.path.basename(sim_name)} → {new_fname}")
# process_track(new_path, sim_name, new_title, new_artist, bitrate)
print(f"Name and tags match ({len(name_and_tags)}):")
# print(" \n".join(name_and_tags))
# print()
# print(f"Name but not tags match ({len(name_not_tags)}):")
# print(" \n".join(name_not_tags))
# print()
print(f"Tags but not name match ({len(tags_not_name)}):")
# print(" \n".join(tags_not_name))
# print()
# print(f"Multiple similar names ({len(multiple_similar)}):")
# print(" \n".join(multiple_similar))
# print()
# print(f"Possibles: ({len(possibles)}):")
# print(" \n".join(possibles))
# print()
# print(f"No match ({len(no_match)}):")
# print(" \n".join(no_match))
# print()
# print(f"Name and tags match ({len(name_and_tags)}):")
# print(f"Name but not tags match ({len(name_not_tags)}):")
# print(f"Tags but not name match ({len(tags_not_name)}):")
# print(f"Multiple similar names ({len(multiple_similar)}):")
# print(f"Possibles: ({len(possibles)}):")
# print(f"No match ({len(no_match)}):")
print(f"No matches: {no_match}")
def process_track(src, dst, title, artist, bitrate):
new_path = os.path.join(os.path.dirname(dst), os.path.basename(src))
print(
f"process_track:\n {src=}\n {dst=}\n {title=}, {artist=}\n"
)
if not do_processing:
return
with Session() as session:
track = Tracks.get_by_path(session, dst)
if track:
# Update path, but workaround MariaDB bug
track.path = new_path
try:
session.commit()
except IntegrityError:
# https://jira.mariadb.org/browse/MDEV-29345 workaround
session.rollback()
track.path = "DUMMY"
session.commit()
track.path = new_path
session.commit()
print(f"os.unlink({dst}")
print(f"shutil.move({src}, {new_path}")
os.unlink(dst)
shutil.move(src, new_path)
# Update track metadata
set_track_metadata(session, track)
main()

View File

@ -165,41 +165,53 @@ border: 1px solid rgb(85, 87, 83);</string>
</widget> </widget>
</item> </item>
<item> <item>
<widget class="QPushButton" name="hdrCurrentTrack"> <widget class="QLabel" name="hdrCurrentTrack">
<property name="font"> <property name="font">
<font> <font>
<family>Sans</family>
<pointsize>20</pointsize> <pointsize>20</pointsize>
</font> </font>
</property> </property>
<property name="styleSheet"> <property name="styleSheet">
<string notr="true">background-color: #d4edda; <string notr="true">background-color: #d4edda;
border: 1px solid rgb(85, 87, 83); border: 1px solid rgb(85, 87, 83);</string>
text-align: left;</string>
</property> </property>
<property name="text"> <property name="text">
<string/> <string/>
</property> </property>
<property name="flat"> <property name="wordWrap">
<bool>true</bool> <bool>true</bool>
</property> </property>
</widget> </widget>
</item> </item>
<item> <item>
<widget class="QPushButton" name="hdrNextTrack"> <widget class="QLabel" name="hdrNextTrack">
<property name="minimumSize">
<size>
<width>0</width>
<height>39</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>16777215</width>
<height>39</height>
</size>
</property>
<property name="font"> <property name="font">
<font> <font>
<family>Sans</family>
<pointsize>20</pointsize> <pointsize>20</pointsize>
</font> </font>
</property> </property>
<property name="styleSheet"> <property name="styleSheet">
<string notr="true">background-color: #fff3cd; <string notr="true">background-color: #fff3cd;
border: 1px solid rgb(85, 87, 83); border: 1px solid rgb(85, 87, 83);</string>
text-align: left;</string>
</property> </property>
<property name="text"> <property name="text">
<string/> <string/>
</property> </property>
<property name="flat"> <property name="wordWrap">
<bool>true</bool> <bool>true</bool>
</property> </property>
</widget> </widget>
@ -270,10 +282,6 @@ text-align: left;</string>
</widget> </widget>
</item> </item>
<item row="2" column="0"> <item row="2" column="0">
<widget class="QSplitter" name="splitter">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<widget class="QTabWidget" name="tabPlaylist"> <widget class="QTabWidget" name="tabPlaylist">
<property name="currentIndex"> <property name="currentIndex">
<number>-1</number> <number>-1</number>
@ -288,21 +296,6 @@ text-align: left;</string>
<bool>true</bool> <bool>true</bool>
</property> </property>
</widget> </widget>
<widget class="InfoTabs" name="tabInfolist">
<property name="currentIndex">
<number>-1</number>
</property>
<property name="documentMode">
<bool>false</bool>
</property>
<property name="tabsClosable">
<bool>true</bool>
</property>
<property name="movable">
<bool>true</bool>
</property>
</widget>
</widget>
</item> </item>
<item row="3" column="0"> <item row="3" column="0">
<layout class="QHBoxLayout" name="horizontalLayout"> <layout class="QHBoxLayout" name="horizontalLayout">
@ -741,64 +734,57 @@ text-align: left;</string>
</property> </property>
<widget class="QMenu" name="menuFile"> <widget class="QMenu" name="menuFile">
<property name="title"> <property name="title">
<string>&amp;Playlists</string> <string>Fi&amp;le</string>
</property> </property>
<addaction name="actionNewPlaylist"/> <addaction name="actionNewPlaylist"/>
<addaction name="actionOpenPlaylist"/> <addaction name="actionOpenPlaylist"/>
<addaction name="actionClosePlaylist"/> <addaction name="actionClosePlaylist"/>
<addaction name="actionRenamePlaylist"/> <addaction name="actionRenamePlaylist"/>
<addaction name="actionExport_playlist"/>
<addaction name="actionDeletePlaylist"/> <addaction name="actionDeletePlaylist"/>
<addaction name="separator"/> <addaction name="separator"/>
<addaction name="actionMoveSelected"/>
<addaction name="actionMoveUnplayed"/>
<addaction name="actionDownload_CSV_of_played_tracks"/> <addaction name="actionDownload_CSV_of_played_tracks"/>
<addaction name="actionExport_playlist"/>
<addaction name="separator"/> <addaction name="separator"/>
<addaction name="actionE_xit"/> <addaction name="actionE_xit"/>
<addaction name="separator"/> <addaction name="separator"/>
</widget> </widget>
<widget class="QMenu" name="menuPlaylist"> <widget class="QMenu" name="menuPlaylist">
<property name="title"> <property name="title">
<string>Sho&amp;wtime</string> <string>&amp;Tracks</string>
</property> </property>
<addaction name="separator"/> <addaction name="separator"/>
<addaction name="actionPlay_next"/> <addaction name="actionSearch_database"/>
<addaction name="actionFade"/> <addaction name="actionAdd_note"/>
<addaction name="actionStop"/>
<addaction name="separator"/>
<addaction name="actionSkipToNext"/>
<addaction name="separator"/>
<addaction name="actionInsertSectionHeader"/>
<addaction name="actionInsertTrack"/>
<addaction name="actionRemove"/>
<addaction name="actionImport"/> <addaction name="actionImport"/>
<addaction name="separator"/>
<addaction name="actionSetNext"/>
<addaction name="action_Clear_selection"/> <addaction name="action_Clear_selection"/>
<addaction name="separator"/> <addaction name="separator"/>
<addaction name="actionEnable_controls"/> <addaction name="actionSetNext"/>
</widget> <addaction name="separator"/>
<widget class="QMenu" name="menuSearc_h"> <addaction name="actionSelect_unplayed_tracks"/>
<property name="title"> <addaction name="actionSelect_played_tracks"/>
<string>&amp;Search</string> <addaction name="actionMoveSelected"/>
</property> <addaction name="separator"/>
<addaction name="actionSearch"/>
<addaction name="actionFind_next"/>
<addaction name="actionFind_previous"/>
<addaction name="separator"/> <addaction name="separator"/>
<addaction name="actionSelect_next_track"/> <addaction name="actionSelect_next_track"/>
<addaction name="actionSelect_previous_track"/> <addaction name="actionSelect_previous_track"/>
<addaction name="separator"/>
<addaction name="actionSearch"/>
</widget> </widget>
<widget class="QMenu" name="menuHelp"> <widget class="QMenu" name="menu_Music">
<property name="title"> <property name="title">
<string>&amp;Help</string> <string>&amp;Music</string>
</property> </property>
<addaction name="action_About"/> <addaction name="actionPlay_next"/>
<addaction name="actionSkip_next"/>
<addaction name="actionFade"/>
<addaction name="actionStop"/>
<addaction name="action_Resume_previous"/>
<addaction name="separator"/>
<addaction name="actionEnable_controls"/>
</widget> </widget>
<addaction name="menuFile"/> <addaction name="menuFile"/>
<addaction name="menuPlaylist"/> <addaction name="menuPlaylist"/>
<addaction name="menuSearc_h"/> <addaction name="menu_Music"/>
<addaction name="menuHelp"/>
</widget> </widget>
<widget class="QStatusBar" name="statusbar"> <widget class="QStatusBar" name="statusbar">
<property name="enabled"> <property name="enabled">
@ -820,7 +806,7 @@ text-align: left;</string>
<string>Return</string> <string>Return</string>
</property> </property>
</action> </action>
<action name="actionSkipToNext"> <action name="actionSkip_next">
<property name="icon"> <property name="icon">
<iconset resource="icons.qrc"> <iconset resource="icons.qrc">
<normaloff>:/icons/next</normaloff>:/icons/next</iconset> <normaloff>:/icons/next</normaloff>:/icons/next</iconset>
@ -832,16 +818,16 @@ text-align: left;</string>
<string>Ctrl+Alt+Return</string> <string>Ctrl+Alt+Return</string>
</property> </property>
</action> </action>
<action name="actionInsertTrack"> <action name="actionSearch_database">
<property name="icon"> <property name="icon">
<iconset> <iconset>
<normaloff>../../../../.designer/backup/icon_search_database.png</normaloff>../../../../.designer/backup/icon_search_database.png</iconset> <normaloff>../../../../.designer/backup/icon_search_database.png</normaloff>../../../../.designer/backup/icon_search_database.png</iconset>
</property> </property>
<property name="text"> <property name="text">
<string>Insert &amp;track...</string> <string>Search &amp;database</string>
</property> </property>
<property name="shortcut"> <property name="shortcut">
<string>Ctrl+T</string> <string>Ctrl+D</string>
</property> </property>
</action> </action>
<action name="actionAdd_file"> <action name="actionAdd_file">
@ -963,7 +949,7 @@ text-align: left;</string>
</action> </action>
<action name="actionExport_playlist"> <action name="actionExport_playlist">
<property name="text"> <property name="text">
<string>E&amp;xport...</string> <string>E&amp;xport playlist...</string>
</property> </property>
</action> </action>
<action name="actionSetNext"> <action name="actionSetNext">
@ -995,9 +981,9 @@ text-align: left;</string>
<string>Select played tracks</string> <string>Select played tracks</string>
</property> </property>
</action> </action>
<action name="actionMoveUnplayed"> <action name="actionSelect_unplayed_tracks">
<property name="text"> <property name="text">
<string>Move &amp;unplayed tracks to...</string> <string>Select unplayed tracks</string>
</property> </property>
</action> </action>
<action name="actionAdd_note"> <action name="actionAdd_note">
@ -1015,7 +1001,7 @@ text-align: left;</string>
</action> </action>
<action name="actionImport"> <action name="actionImport">
<property name="text"> <property name="text">
<string>Import track...</string> <string>Import...</string>
</property> </property>
<property name="shortcut"> <property name="shortcut">
<string>Ctrl+Shift+I</string> <string>Ctrl+Shift+I</string>
@ -1034,49 +1020,7 @@ text-align: left;</string>
<string>/</string> <string>/</string>
</property> </property>
</action> </action>
<action name="actionInsertSectionHeader">
<property name="text">
<string>Insert &amp;section header...</string>
</property>
<property name="shortcut">
<string>Ctrl+H</string>
</property>
</action>
<action name="actionRemove">
<property name="text">
<string>&amp;Remove track</string>
</property>
</action>
<action name="actionFind_next">
<property name="text">
<string>Find next</string>
</property>
<property name="shortcut">
<string>N</string>
</property>
</action>
<action name="actionFind_previous">
<property name="text">
<string>Find previous</string>
</property>
<property name="shortcut">
<string>P</string>
</property>
</action>
<action name="action_About">
<property name="text">
<string>&amp;About</string>
</property>
</action>
</widget> </widget>
<customwidgets>
<customwidget>
<class>InfoTabs</class>
<extends>QTabWidget</extends>
<header>infotabs</header>
<container>1</container>
</customwidget>
</customwidgets>
<resources> <resources>
<include location="icons.qrc"/> <include location="icons.qrc"/>
</resources> </resources>

View File

@ -92,26 +92,28 @@ class Ui_MainWindow(object):
self.hdrPreviousTrack.setWordWrap(True) self.hdrPreviousTrack.setWordWrap(True)
self.hdrPreviousTrack.setObjectName("hdrPreviousTrack") self.hdrPreviousTrack.setObjectName("hdrPreviousTrack")
self.verticalLayout.addWidget(self.hdrPreviousTrack) self.verticalLayout.addWidget(self.hdrPreviousTrack)
self.hdrCurrentTrack = QtWidgets.QPushButton(self.centralwidget) self.hdrCurrentTrack = QtWidgets.QLabel(self.centralwidget)
font = QtGui.QFont() font = QtGui.QFont()
font.setFamily("Sans")
font.setPointSize(20) font.setPointSize(20)
self.hdrCurrentTrack.setFont(font) self.hdrCurrentTrack.setFont(font)
self.hdrCurrentTrack.setStyleSheet("background-color: #d4edda;\n" self.hdrCurrentTrack.setStyleSheet("background-color: #d4edda;\n"
"border: 1px solid rgb(85, 87, 83);\n" "border: 1px solid rgb(85, 87, 83);")
"text-align: left;")
self.hdrCurrentTrack.setText("") self.hdrCurrentTrack.setText("")
self.hdrCurrentTrack.setFlat(True) self.hdrCurrentTrack.setWordWrap(True)
self.hdrCurrentTrack.setObjectName("hdrCurrentTrack") self.hdrCurrentTrack.setObjectName("hdrCurrentTrack")
self.verticalLayout.addWidget(self.hdrCurrentTrack) self.verticalLayout.addWidget(self.hdrCurrentTrack)
self.hdrNextTrack = QtWidgets.QPushButton(self.centralwidget) self.hdrNextTrack = QtWidgets.QLabel(self.centralwidget)
self.hdrNextTrack.setMinimumSize(QtCore.QSize(0, 39))
self.hdrNextTrack.setMaximumSize(QtCore.QSize(16777215, 39))
font = QtGui.QFont() font = QtGui.QFont()
font.setFamily("Sans")
font.setPointSize(20) font.setPointSize(20)
self.hdrNextTrack.setFont(font) self.hdrNextTrack.setFont(font)
self.hdrNextTrack.setStyleSheet("background-color: #fff3cd;\n" self.hdrNextTrack.setStyleSheet("background-color: #fff3cd;\n"
"border: 1px solid rgb(85, 87, 83);\n" "border: 1px solid rgb(85, 87, 83);")
"text-align: left;")
self.hdrNextTrack.setText("") self.hdrNextTrack.setText("")
self.hdrNextTrack.setFlat(True) self.hdrNextTrack.setWordWrap(True)
self.hdrNextTrack.setObjectName("hdrNextTrack") self.hdrNextTrack.setObjectName("hdrNextTrack")
self.verticalLayout.addWidget(self.hdrNextTrack) self.verticalLayout.addWidget(self.hdrNextTrack)
self.horizontalLayout_3.addLayout(self.verticalLayout) self.horizontalLayout_3.addLayout(self.verticalLayout)
@ -140,20 +142,12 @@ class Ui_MainWindow(object):
self.frame_4.setFrameShadow(QtWidgets.QFrame.Raised) self.frame_4.setFrameShadow(QtWidgets.QFrame.Raised)
self.frame_4.setObjectName("frame_4") self.frame_4.setObjectName("frame_4")
self.gridLayout_4.addWidget(self.frame_4, 1, 0, 1, 1) self.gridLayout_4.addWidget(self.frame_4, 1, 0, 1, 1)
self.splitter = QtWidgets.QSplitter(self.centralwidget) self.tabPlaylist = QtWidgets.QTabWidget(self.centralwidget)
self.splitter.setOrientation(QtCore.Qt.Vertical)
self.splitter.setObjectName("splitter")
self.tabPlaylist = QtWidgets.QTabWidget(self.splitter)
self.tabPlaylist.setDocumentMode(False) self.tabPlaylist.setDocumentMode(False)
self.tabPlaylist.setTabsClosable(True) self.tabPlaylist.setTabsClosable(True)
self.tabPlaylist.setMovable(True) self.tabPlaylist.setMovable(True)
self.tabPlaylist.setObjectName("tabPlaylist") self.tabPlaylist.setObjectName("tabPlaylist")
self.tabInfolist = InfoTabs(self.splitter) self.gridLayout_4.addWidget(self.tabPlaylist, 2, 0, 1, 1)
self.tabInfolist.setDocumentMode(False)
self.tabInfolist.setTabsClosable(True)
self.tabInfolist.setMovable(True)
self.tabInfolist.setObjectName("tabInfolist")
self.gridLayout_4.addWidget(self.splitter, 2, 0, 1, 1)
self.horizontalLayout = QtWidgets.QHBoxLayout() self.horizontalLayout = QtWidgets.QHBoxLayout()
self.horizontalLayout.setObjectName("horizontalLayout") self.horizontalLayout.setObjectName("horizontalLayout")
self.frame = QtWidgets.QFrame(self.centralwidget) self.frame = QtWidgets.QFrame(self.centralwidget)
@ -348,10 +342,8 @@ class Ui_MainWindow(object):
self.menuFile.setObjectName("menuFile") self.menuFile.setObjectName("menuFile")
self.menuPlaylist = QtWidgets.QMenu(self.menubar) self.menuPlaylist = QtWidgets.QMenu(self.menubar)
self.menuPlaylist.setObjectName("menuPlaylist") self.menuPlaylist.setObjectName("menuPlaylist")
self.menuSearc_h = QtWidgets.QMenu(self.menubar) self.menu_Music = QtWidgets.QMenu(self.menubar)
self.menuSearc_h.setObjectName("menuSearc_h") self.menu_Music.setObjectName("menu_Music")
self.menuHelp = QtWidgets.QMenu(self.menubar)
self.menuHelp.setObjectName("menuHelp")
MainWindow.setMenuBar(self.menubar) MainWindow.setMenuBar(self.menubar)
self.statusbar = QtWidgets.QStatusBar(MainWindow) self.statusbar = QtWidgets.QStatusBar(MainWindow)
self.statusbar.setEnabled(True) self.statusbar.setEnabled(True)
@ -363,16 +355,16 @@ class Ui_MainWindow(object):
icon3.addPixmap(QtGui.QPixmap("app/ui/../../../../.designer/backup/icon-play.png"), QtGui.QIcon.Normal, QtGui.QIcon.Off) icon3.addPixmap(QtGui.QPixmap("app/ui/../../../../.designer/backup/icon-play.png"), QtGui.QIcon.Normal, QtGui.QIcon.Off)
self.actionPlay_next.setIcon(icon3) self.actionPlay_next.setIcon(icon3)
self.actionPlay_next.setObjectName("actionPlay_next") self.actionPlay_next.setObjectName("actionPlay_next")
self.actionSkipToNext = QtWidgets.QAction(MainWindow) self.actionSkip_next = QtWidgets.QAction(MainWindow)
icon4 = QtGui.QIcon() icon4 = QtGui.QIcon()
icon4.addPixmap(QtGui.QPixmap(":/icons/next"), QtGui.QIcon.Normal, QtGui.QIcon.Off) icon4.addPixmap(QtGui.QPixmap(":/icons/next"), QtGui.QIcon.Normal, QtGui.QIcon.Off)
self.actionSkipToNext.setIcon(icon4) self.actionSkip_next.setIcon(icon4)
self.actionSkipToNext.setObjectName("actionSkipToNext") self.actionSkip_next.setObjectName("actionSkip_next")
self.actionInsertTrack = QtWidgets.QAction(MainWindow) self.actionSearch_database = QtWidgets.QAction(MainWindow)
icon5 = QtGui.QIcon() icon5 = QtGui.QIcon()
icon5.addPixmap(QtGui.QPixmap("app/ui/../../../../.designer/backup/icon_search_database.png"), QtGui.QIcon.Normal, QtGui.QIcon.Off) icon5.addPixmap(QtGui.QPixmap("app/ui/../../../../.designer/backup/icon_search_database.png"), QtGui.QIcon.Normal, QtGui.QIcon.Off)
self.actionInsertTrack.setIcon(icon5) self.actionSearch_database.setIcon(icon5)
self.actionInsertTrack.setObjectName("actionInsertTrack") self.actionSearch_database.setObjectName("actionSearch_database")
self.actionAdd_file = QtWidgets.QAction(MainWindow) self.actionAdd_file = QtWidgets.QAction(MainWindow)
icon6 = QtGui.QIcon() icon6 = QtGui.QIcon()
icon6.addPixmap(QtGui.QPixmap("app/ui/../../../../.designer/backup/icon_open_file.png"), QtGui.QIcon.Normal, QtGui.QIcon.Off) icon6.addPixmap(QtGui.QPixmap("app/ui/../../../../.designer/backup/icon_open_file.png"), QtGui.QIcon.Normal, QtGui.QIcon.Off)
@ -430,8 +422,8 @@ class Ui_MainWindow(object):
self.actionSelect_previous_track.setObjectName("actionSelect_previous_track") self.actionSelect_previous_track.setObjectName("actionSelect_previous_track")
self.actionSelect_played_tracks = QtWidgets.QAction(MainWindow) self.actionSelect_played_tracks = QtWidgets.QAction(MainWindow)
self.actionSelect_played_tracks.setObjectName("actionSelect_played_tracks") self.actionSelect_played_tracks.setObjectName("actionSelect_played_tracks")
self.actionMoveUnplayed = QtWidgets.QAction(MainWindow) self.actionSelect_unplayed_tracks = QtWidgets.QAction(MainWindow)
self.actionMoveUnplayed.setObjectName("actionMoveUnplayed") self.actionSelect_unplayed_tracks.setObjectName("actionSelect_unplayed_tracks")
self.actionAdd_note = QtWidgets.QAction(MainWindow) self.actionAdd_note = QtWidgets.QAction(MainWindow)
self.actionAdd_note.setObjectName("actionAdd_note") self.actionAdd_note.setObjectName("actionAdd_note")
self.actionEnable_controls = QtWidgets.QAction(MainWindow) self.actionEnable_controls = QtWidgets.QAction(MainWindow)
@ -442,60 +434,47 @@ class Ui_MainWindow(object):
self.actionDownload_CSV_of_played_tracks.setObjectName("actionDownload_CSV_of_played_tracks") self.actionDownload_CSV_of_played_tracks.setObjectName("actionDownload_CSV_of_played_tracks")
self.actionSearch = QtWidgets.QAction(MainWindow) self.actionSearch = QtWidgets.QAction(MainWindow)
self.actionSearch.setObjectName("actionSearch") self.actionSearch.setObjectName("actionSearch")
self.actionInsertSectionHeader = QtWidgets.QAction(MainWindow)
self.actionInsertSectionHeader.setObjectName("actionInsertSectionHeader")
self.actionRemove = QtWidgets.QAction(MainWindow)
self.actionRemove.setObjectName("actionRemove")
self.actionFind_next = QtWidgets.QAction(MainWindow)
self.actionFind_next.setObjectName("actionFind_next")
self.actionFind_previous = QtWidgets.QAction(MainWindow)
self.actionFind_previous.setObjectName("actionFind_previous")
self.action_About = QtWidgets.QAction(MainWindow)
self.action_About.setObjectName("action_About")
self.menuFile.addAction(self.actionNewPlaylist) self.menuFile.addAction(self.actionNewPlaylist)
self.menuFile.addAction(self.actionOpenPlaylist) self.menuFile.addAction(self.actionOpenPlaylist)
self.menuFile.addAction(self.actionClosePlaylist) self.menuFile.addAction(self.actionClosePlaylist)
self.menuFile.addAction(self.actionRenamePlaylist) self.menuFile.addAction(self.actionRenamePlaylist)
self.menuFile.addAction(self.actionExport_playlist)
self.menuFile.addAction(self.actionDeletePlaylist) self.menuFile.addAction(self.actionDeletePlaylist)
self.menuFile.addSeparator() self.menuFile.addSeparator()
self.menuFile.addAction(self.actionMoveSelected)
self.menuFile.addAction(self.actionMoveUnplayed)
self.menuFile.addAction(self.actionDownload_CSV_of_played_tracks) self.menuFile.addAction(self.actionDownload_CSV_of_played_tracks)
self.menuFile.addAction(self.actionExport_playlist)
self.menuFile.addSeparator() self.menuFile.addSeparator()
self.menuFile.addAction(self.actionE_xit) self.menuFile.addAction(self.actionE_xit)
self.menuFile.addSeparator() self.menuFile.addSeparator()
self.menuPlaylist.addSeparator() self.menuPlaylist.addSeparator()
self.menuPlaylist.addAction(self.actionPlay_next) self.menuPlaylist.addAction(self.actionSearch_database)
self.menuPlaylist.addAction(self.actionFade) self.menuPlaylist.addAction(self.actionAdd_note)
self.menuPlaylist.addAction(self.actionStop)
self.menuPlaylist.addSeparator()
self.menuPlaylist.addAction(self.actionSkipToNext)
self.menuPlaylist.addSeparator()
self.menuPlaylist.addAction(self.actionInsertSectionHeader)
self.menuPlaylist.addAction(self.actionInsertTrack)
self.menuPlaylist.addAction(self.actionRemove)
self.menuPlaylist.addAction(self.actionImport) self.menuPlaylist.addAction(self.actionImport)
self.menuPlaylist.addSeparator()
self.menuPlaylist.addAction(self.actionSetNext)
self.menuPlaylist.addAction(self.action_Clear_selection) self.menuPlaylist.addAction(self.action_Clear_selection)
self.menuPlaylist.addSeparator() self.menuPlaylist.addSeparator()
self.menuPlaylist.addAction(self.actionEnable_controls) self.menuPlaylist.addAction(self.actionSetNext)
self.menuSearc_h.addAction(self.actionSearch) self.menuPlaylist.addSeparator()
self.menuSearc_h.addAction(self.actionFind_next) self.menuPlaylist.addAction(self.actionSelect_unplayed_tracks)
self.menuSearc_h.addAction(self.actionFind_previous) self.menuPlaylist.addAction(self.actionSelect_played_tracks)
self.menuSearc_h.addSeparator() self.menuPlaylist.addAction(self.actionMoveSelected)
self.menuSearc_h.addAction(self.actionSelect_next_track) self.menuPlaylist.addSeparator()
self.menuSearc_h.addAction(self.actionSelect_previous_track) self.menuPlaylist.addSeparator()
self.menuHelp.addAction(self.action_About) self.menuPlaylist.addAction(self.actionSelect_next_track)
self.menuPlaylist.addAction(self.actionSelect_previous_track)
self.menuPlaylist.addSeparator()
self.menuPlaylist.addAction(self.actionSearch)
self.menu_Music.addAction(self.actionPlay_next)
self.menu_Music.addAction(self.actionSkip_next)
self.menu_Music.addAction(self.actionFade)
self.menu_Music.addAction(self.actionStop)
self.menu_Music.addAction(self.action_Resume_previous)
self.menu_Music.addSeparator()
self.menu_Music.addAction(self.actionEnable_controls)
self.menubar.addAction(self.menuFile.menuAction()) self.menubar.addAction(self.menuFile.menuAction())
self.menubar.addAction(self.menuPlaylist.menuAction()) self.menubar.addAction(self.menuPlaylist.menuAction())
self.menubar.addAction(self.menuSearc_h.menuAction()) self.menubar.addAction(self.menu_Music.menuAction())
self.menubar.addAction(self.menuHelp.menuAction())
self.retranslateUi(MainWindow) self.retranslateUi(MainWindow)
self.tabPlaylist.setCurrentIndex(-1) self.tabPlaylist.setCurrentIndex(-1)
self.tabInfolist.setCurrentIndex(-1)
self.actionE_xit.triggered.connect(MainWindow.close) # type: ignore self.actionE_xit.triggered.connect(MainWindow.close) # type: ignore
QtCore.QMetaObject.connectSlotsByName(MainWindow) QtCore.QMetaObject.connectSlotsByName(MainWindow)
@ -526,16 +505,15 @@ class Ui_MainWindow(object):
self.label_end_timer.setText(_translate("MainWindow", "00:00")) self.label_end_timer.setText(_translate("MainWindow", "00:00"))
self.btnDrop3db.setText(_translate("MainWindow", "-3dB to talk")) self.btnDrop3db.setText(_translate("MainWindow", "-3dB to talk"))
self.btnHidePlayed.setText(_translate("MainWindow", "Hide played")) self.btnHidePlayed.setText(_translate("MainWindow", "Hide played"))
self.menuFile.setTitle(_translate("MainWindow", "&Playlists")) self.menuFile.setTitle(_translate("MainWindow", "Fi&le"))
self.menuPlaylist.setTitle(_translate("MainWindow", "Sho&wtime")) self.menuPlaylist.setTitle(_translate("MainWindow", "&Tracks"))
self.menuSearc_h.setTitle(_translate("MainWindow", "&Search")) self.menu_Music.setTitle(_translate("MainWindow", "&Music"))
self.menuHelp.setTitle(_translate("MainWindow", "&Help"))
self.actionPlay_next.setText(_translate("MainWindow", "&Play next")) self.actionPlay_next.setText(_translate("MainWindow", "&Play next"))
self.actionPlay_next.setShortcut(_translate("MainWindow", "Return")) self.actionPlay_next.setShortcut(_translate("MainWindow", "Return"))
self.actionSkipToNext.setText(_translate("MainWindow", "Skip to &next")) self.actionSkip_next.setText(_translate("MainWindow", "Skip to &next"))
self.actionSkipToNext.setShortcut(_translate("MainWindow", "Ctrl+Alt+Return")) self.actionSkip_next.setShortcut(_translate("MainWindow", "Ctrl+Alt+Return"))
self.actionInsertTrack.setText(_translate("MainWindow", "Insert &track...")) self.actionSearch_database.setText(_translate("MainWindow", "Search &database"))
self.actionInsertTrack.setShortcut(_translate("MainWindow", "Ctrl+T")) self.actionSearch_database.setShortcut(_translate("MainWindow", "Ctrl+D"))
self.actionAdd_file.setText(_translate("MainWindow", "Add &file")) self.actionAdd_file.setText(_translate("MainWindow", "Add &file"))
self.actionAdd_file.setShortcut(_translate("MainWindow", "Ctrl+F")) self.actionAdd_file.setShortcut(_translate("MainWindow", "Ctrl+F"))
self.actionFade.setText(_translate("MainWindow", "F&ade")) self.actionFade.setText(_translate("MainWindow", "F&ade"))
@ -556,7 +534,7 @@ class Ui_MainWindow(object):
self.actionRenamePlaylist.setText(_translate("MainWindow", "&Rename...")) self.actionRenamePlaylist.setText(_translate("MainWindow", "&Rename..."))
self.actionDeletePlaylist.setText(_translate("MainWindow", "Dele&te...")) self.actionDeletePlaylist.setText(_translate("MainWindow", "Dele&te..."))
self.actionMoveSelected.setText(_translate("MainWindow", "Mo&ve selected tracks to...")) self.actionMoveSelected.setText(_translate("MainWindow", "Mo&ve selected tracks to..."))
self.actionExport_playlist.setText(_translate("MainWindow", "E&xport...")) self.actionExport_playlist.setText(_translate("MainWindow", "E&xport playlist..."))
self.actionSetNext.setText(_translate("MainWindow", "Set &next")) self.actionSetNext.setText(_translate("MainWindow", "Set &next"))
self.actionSetNext.setShortcut(_translate("MainWindow", "Ctrl+N")) self.actionSetNext.setShortcut(_translate("MainWindow", "Ctrl+N"))
self.actionSelect_next_track.setText(_translate("MainWindow", "Select next track")) self.actionSelect_next_track.setText(_translate("MainWindow", "Select next track"))
@ -564,22 +542,13 @@ class Ui_MainWindow(object):
self.actionSelect_previous_track.setText(_translate("MainWindow", "Select previous track")) self.actionSelect_previous_track.setText(_translate("MainWindow", "Select previous track"))
self.actionSelect_previous_track.setShortcut(_translate("MainWindow", "K")) self.actionSelect_previous_track.setShortcut(_translate("MainWindow", "K"))
self.actionSelect_played_tracks.setText(_translate("MainWindow", "Select played tracks")) self.actionSelect_played_tracks.setText(_translate("MainWindow", "Select played tracks"))
self.actionMoveUnplayed.setText(_translate("MainWindow", "Move &unplayed tracks to...")) self.actionSelect_unplayed_tracks.setText(_translate("MainWindow", "Select unplayed tracks"))
self.actionAdd_note.setText(_translate("MainWindow", "Add note...")) self.actionAdd_note.setText(_translate("MainWindow", "Add note..."))
self.actionAdd_note.setShortcut(_translate("MainWindow", "Ctrl+T")) self.actionAdd_note.setShortcut(_translate("MainWindow", "Ctrl+T"))
self.actionEnable_controls.setText(_translate("MainWindow", "Enable controls")) self.actionEnable_controls.setText(_translate("MainWindow", "Enable controls"))
self.actionImport.setText(_translate("MainWindow", "Import track...")) self.actionImport.setText(_translate("MainWindow", "Import..."))
self.actionImport.setShortcut(_translate("MainWindow", "Ctrl+Shift+I")) self.actionImport.setShortcut(_translate("MainWindow", "Ctrl+Shift+I"))
self.actionDownload_CSV_of_played_tracks.setText(_translate("MainWindow", "Download CSV of played tracks...")) self.actionDownload_CSV_of_played_tracks.setText(_translate("MainWindow", "Download CSV of played tracks..."))
self.actionSearch.setText(_translate("MainWindow", "Search...")) self.actionSearch.setText(_translate("MainWindow", "Search..."))
self.actionSearch.setShortcut(_translate("MainWindow", "/")) self.actionSearch.setShortcut(_translate("MainWindow", "/"))
self.actionInsertSectionHeader.setText(_translate("MainWindow", "Insert &section header..."))
self.actionInsertSectionHeader.setShortcut(_translate("MainWindow", "Ctrl+H"))
self.actionRemove.setText(_translate("MainWindow", "&Remove track"))
self.actionFind_next.setText(_translate("MainWindow", "Find next"))
self.actionFind_next.setShortcut(_translate("MainWindow", "N"))
self.actionFind_previous.setText(_translate("MainWindow", "Find previous"))
self.actionFind_previous.setShortcut(_translate("MainWindow", "P"))
self.action_About.setText(_translate("MainWindow", "&About"))
from infotabs import InfoTabs
import icons_rc import icons_rc

View File

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

View File

@ -1,47 +1,214 @@
# #!/usr/bin/env python #!/usr/bin/env python
#
import os
import argparse
import os
import shutil
import tempfile
import helpers
from config import Config from config import Config
from helpers import ( from helpers import (
fade_point, fade_point,
get_audio_segment, get_audio_segment,
get_tags, get_tags,
leading_silence, leading_silence,
normalise_track,
set_track_metadata,
trailing_silence, trailing_silence,
) )
from log import log from log import DEBUG, INFO
from models import Tracks from models import Notes, Playdates, Session, Tracks
from mutagen.flac import FLAC
from mutagen.mp3 import MP3
from pydub import effects
# Globals (I know)
messages = []
def create_track(session, path, normalise=None): def main():
"""Main loop"""
DEBUG("Starting")
p = argparse.ArgumentParser()
# Only allow one option to be specified
group = p.add_mutually_exclusive_group()
group.add_argument('-u', '--update',
action="store_true", dest="update",
default=False, help="Update database")
group.add_argument('-f', '--full-update',
action="store_true", dest="full_update",
default=False, help="Update database")
args = p.parse_args()
# Run as required
if args.update:
DEBUG("Updating database")
with Session() as session:
update_db(session)
elif args.full_update:
DEBUG("Full update of database")
with Session() as session:
full_update_db(session)
else:
INFO("No action specified")
DEBUG("Finished")
def create_track_from_file(session, path, normalise=None, tags=None):
""" """
Create track in database from passed path. Create track in database from passed path, or update database entry
if path already in database.
Return track. Return track.
""" """
track = Tracks(session, path) if not tags:
t = get_tags(path)
else:
t = tags
track = Tracks.get_or_create(session, path)
track.title = t['title']
track.artist = t['artist']
audio = get_audio_segment(path)
track.duration = len(audio)
track.start_gap = leading_silence(audio)
track.fade_at = round(fade_point(audio) / 1000,
Config.MILLISECOND_SIGFIGS) * 1000
track.silence_at = round(trailing_silence(audio) / 1000,
Config.MILLISECOND_SIGFIGS) * 1000
track.mtime = os.path.getmtime(path)
session.commit()
set_track_metadata(session, track)
if normalise or normalise is None and Config.NORMALISE_ON_IMPORT: if normalise or normalise is None and Config.NORMALISE_ON_IMPORT:
normalise_track(path) # Check type
ftype = os.path.splitext(path)[1][1:]
if ftype not in ['mp3', 'flac']:
INFO(f"File type {ftype} not implemented")
return track
# Get current file gid, uid and permissions
stats = os.stat(path)
try:
# Copy original file
fd, temp_path = tempfile.mkstemp()
shutil.copyfile(path, temp_path)
except Exception as err:
DEBUG(f"songdb.create_track_from_file({path}): err1: {repr(err)}")
return
# Overwrite original file with normalised output
normalised = effects.normalize(audio)
try:
normalised.export(path, format=os.path.splitext(path)[1][1:])
# Fix up permssions and ownership
os.chown(path, stats.st_uid, stats.st_gid)
os.chmod(path, stats.st_mode)
# Copy tags
if ftype == 'flac':
tag_handler = FLAC
elif ftype == 'mp3':
tag_handler = MP3
else:
return track
src = tag_handler(temp_path)
dst = tag_handler(path)
for tag in src:
dst[tag] = src[tag]
dst.save()
except Exception as err:
DEBUG(f"songdb.create_track_from_file({path}): err2: {repr(err)}")
# Restore original file
shutil.copyfile(path, temp_path)
finally:
if os.path.exists(temp_path):
os.remove(temp_path)
return track
def check_db(session): def full_update_db(session):
"""Rescan all entries in database"""
def log(msg):
INFO(f"full_update_db(): {msg}")
def check_change(track_id, title, attribute, old, new):
if new > (old * 1.1) or new < (old * 0.9):
log(
"\n"
f"track[{track_id}] ({title}) "
f"{attribute} updated from {old} to {new}"
)
# Start with normal update to add new tracks and remove any missing
# files
log("update_db()")
update_db(session)
# Now update track length, silence and fade for every track in
# database
tracks = Tracks.get_all_tracks(session)
total_tracks = len(tracks)
log(f"Processing {total_tracks} tracks")
track_count = 0
for track in tracks:
track_count += 1
print(f"\rTrack {track_count} of {total_tracks}", end='')
# Sanity check
tag = get_music_info(track.path)
if not tag['title']:
log(f"track[{track.id}] {track.title=}: No tag title")
continue
if not tag['artist']:
log(f"track[{track.id}] {track.artist=}: No tag artist")
continue
# Update title and artist
if track.title != tag['title']:
track.title = tag['title']
if track.artist != tag['artist']:
track.artist = tag['artist']
# Update numbers; log if more than 10% different
duration = int(round(
tag['duration'], Config.MILLISECOND_SIGFIGS) * 1000)
check_change(track.id, track.title, "duration", track.duration,
duration)
track.duration = duration
audio = get_audio_segment(track.path)
start_gap = leading_silence(audio)
check_change(track.id, track.title, "start_gap", track.start_gap,
start_gap)
track.start_gap = start_gap
fade_at = fade_point(audio)
check_change(track.id, track.title, "fade_at", track.fade_at,
fade_at)
track.fade_at = fade_at
silence_at = trailing_silence(audio)
check_change(track.id, track.title, "silence_at", track.silence_at,
silence_at)
track.silence_at = silence_at
session.commit()
def update_db(session):
""" """
Database consistency check. Repopulate database
A report is generated if issues are found, but there are no automatic
corrections made.
Search for tracks that are in the music directory but not the datebase
Check all paths in database exist
""" """
db_paths = set([a.path for a in Tracks.get_all(session)]) # Search for tracks that are in the music directory but not the datebase
# Check all paths in database exist
# If issues found, write to stdout but do not try to resolve them
db_paths = set(Tracks.get_all_paths(session))
os_paths_list = [] os_paths_list = []
for root, dirs, files in os.walk(Config.ROOT): for root, dirs, files in os.walk(Config.ROOT):
@ -68,9 +235,7 @@ def check_db(session):
track = Tracks.get_by_path(session, path) track = Tracks.get_by_path(session, path)
if not track: if not track:
# This shouldn't happen as we're looking for paths in ERROR(f"update_db: {path} not found in db")
# database that aren't in filesystem, but just in case...
log.error(f"update_db: {path} not found in db")
continue continue
paths_not_found.append(track) paths_not_found.append(track)
@ -96,14 +261,32 @@ def check_db(session):
print("There were more paths than listed that were not found") print("There were more paths than listed that were not found")
def update_bitrates(session): # Spike
""" #
Update bitrates on all tracks in database # # Manage tracks listed in database but where path is invalid
""" # DEBUG(f"Invalid {path=} in database", True)
# track = Tracks.get_by_path(session, path)
# messages.append(f"Remove from database: {path=} {track=}")
#
# # Remove references from Playdates
# Playdates.remove_track(session, track.id)
#
# # Replace playlist entries with a note
# note_txt = (
# f"File removed: {track.title=}, {track.artist=}, "
# f"{track.path=}"
# )
# for playlist_track in track.playlists:
# row = playlist_track.row
# # Remove playlist entry
# DEBUG(f"Remove {row=} from {playlist_track.playlist_id}", True)
# playlist_track.playlist.remove_track(session, row)
# # Create note
# DEBUG(f"Add note at {row=} to {playlist_track.playlist_id=}", True)
# Notes(session, playlist_track.playlist_id, row, note_txt)
#
# # Remove Track entry pointing to invalid path
# Tracks.remove_by_path(session, path)
for track in Tracks.get_all(session): if __name__ == '__main__' and '__file__' in globals():
try: main()
t = get_tags(track.path)
track.bitrate = t["bitrate"]
except FileNotFoundError:
continue

View File

@ -1,9 +0,0 @@
from sqlalchemy.orm import (sessionmaker, scoped_session)
s = sessionmaker(bind=engine)
from dbconfig import engine
s = sessionmaker(bind=engine)
s
playlist in s
s = scoped_session(sessionmaker(bind=engine))
playlist_id = 3
playlist = Playlists.get_by_id(session, playlist_id)

View File

@ -1,32 +0,0 @@
"""Add 'played' column to playlist_rows
Revision ID: 0c604bf490f8
Revises: 29c0d7ffc741
Create Date: 2022-08-12 14:12:38.419845
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = '0c604bf490f8'
down_revision = '29c0d7ffc741'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('playlist_rows', sa.Column('played', sa.Boolean(), nullable=False))
op.drop_index('ix_tracks_lastplayed', table_name='tracks')
op.drop_column('tracks', 'lastplayed')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('tracks', sa.Column('lastplayed', mysql.DATETIME(), nullable=True))
op.create_index('ix_tracks_lastplayed', 'tracks', ['lastplayed'], unique=False)
op.drop_column('playlist_rows', 'played')
# ### end Alembic commands ###

View File

@ -1,24 +0,0 @@
"""Drop uniquerow index on playlist_rows
Revision ID: 29c0d7ffc741
Revises: 3b063011ed67
Create Date: 2022-08-06 22:21:46.881378
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '29c0d7ffc741'
down_revision = '3b063011ed67'
branch_labels = None
depends_on = None
def upgrade():
op.drop_index('uniquerow', table_name='playlist_rows')
def downgrade():
op.create_index('uniquerow', 'playlist_rows', ['row_number', 'playlist_id'], unique=True)

View File

@ -1,54 +0,0 @@
"""schema changes for row notes
Revision ID: 3b063011ed67
Revises: 51f61433256f
Create Date: 2022-07-06 19:48:23.960471
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = '3b063011ed67'
down_revision = '51f61433256f'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('notes')
op.add_column('playlist_rows', sa.Column('note', sa.String(length=2048), nullable=True))
op.alter_column('playlist_rows', 'track_id',
existing_type=mysql.INTEGER(display_width=11),
nullable=True)
op.drop_index('uniquerow', table_name='playlist_rows')
op.drop_column('playlist_rows', 'text')
op.alter_column('playlist_rows', 'row', new_column_name='row_number',
existing_type=mysql.INTEGER(display_width=11),
nullable=False)
op.create_index('uniquerow', 'playlist_rows', ['row_number', 'playlist_id'], unique=True)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('playlist_rows', 'row_number', new_column_name='row',
existing_type=mysql.INTEGER(display_width=11),
nullable=False)
op.add_column('playlist_rows', sa.Column('text', mysql.VARCHAR(length=2048), nullable=True))
op.drop_index('uniquerow', table_name='playlist_rows')
op.create_index('uniquerow', 'playlist_rows', ['row', 'playlist_id'], unique=False)
op.drop_column('playlist_rows', 'note')
op.create_table('notes',
sa.Column('id', mysql.INTEGER(display_width=11), autoincrement=True, nullable=False),
sa.Column('playlist_id', mysql.INTEGER(display_width=11), autoincrement=False, nullable=True),
sa.Column('row', mysql.INTEGER(display_width=11), autoincrement=False, nullable=False),
sa.Column('note', mysql.VARCHAR(length=256), nullable=True),
sa.ForeignKeyConstraint(['playlist_id'], ['playlists.id'], name='notes_ibfk_1'),
sa.PrimaryKeyConstraint('id'),
mysql_default_charset='utf8mb4',
mysql_engine='InnoDB'
)
# ### end Alembic commands ###

View File

@ -1,26 +0,0 @@
"""Rename playlist_tracks to playlist_rows
Revision ID: 3f55ac7d80ad
Revises: 1c4048efee96
Create Date: 2022-07-04 20:51:59.874004
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '3f55ac7d80ad'
down_revision = '1c4048efee96'
branch_labels = None
depends_on = None
def upgrade():
# Rename so as not to lose content
op.rename_table('playlist_tracks', 'playlist_rows')
def downgrade():
# Rename so as not to lose content
op.rename_table('playlist_rows', 'playlist_tracks')

View File

@ -1,34 +0,0 @@
"""Increase settings.name len and add playlist_rows.notes
Revision ID: 51f61433256f
Revises: 3f55ac7d80ad
Create Date: 2022-07-04 21:21:39.830406
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = '51f61433256f'
down_revision = '3f55ac7d80ad'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('playlist_rows', sa.Column('text', sa.String(length=2048), nullable=True))
op.alter_column('playlists', 'loaded',
existing_type=mysql.TINYINT(display_width=1),
nullable=False)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('playlists', 'loaded',
existing_type=mysql.TINYINT(display_width=1),
nullable=True)
op.drop_column('playlist_rows', 'text')
# ### end Alembic commands ###

View File

@ -1,28 +0,0 @@
"""Add column for bitrate in Tracks
Revision ID: ed3100326c38
Revises: fe2e127b3332
Create Date: 2022-08-22 16:16:42.181848
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'ed3100326c38'
down_revision = 'fe2e127b3332'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('tracks', sa.Column('bitrate', sa.Integer(), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('tracks', 'bitrate')
# ### end Alembic commands ###

View File

@ -1,28 +0,0 @@
"""Don't allow duplicate track paths
Revision ID: fe2e127b3332
Revises: 0c604bf490f8
Create Date: 2022-08-21 19:46:35.768659
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'fe2e127b3332'
down_revision = '0c604bf490f8'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_unique_constraint(None, 'tracks', ['path'])
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, 'tracks', type_='unique')
# ### end Alembic commands ###

94
play.py
View File

@ -1,94 +0,0 @@
#!/usr/bin/env python
from sqlalchemy import create_engine
from sqlalchemy import text
from sqlalchemy import Table, Column, Integer, String
from sqlalchemy import ForeignKey
from sqlalchemy import select
from sqlalchemy import insert
from sqlalchemy.orm import Session
from sqlalchemy.orm import declarative_base
from sqlalchemy.orm import relationship
Base = declarative_base()
engine = create_engine("sqlite+pysqlite:///:memory:", echo=True, future=True)
class User(Base):
__tablename__ = 'user_account'
id = Column(Integer, primary_key=True)
name = Column(String(30))
fullname = Column(String)
addresses = relationship("Address", back_populates="user")
def __repr__(self):
return (
f"User(id={self.id!r}, name={self.name!r}, "
f"fullname={self.fullname!r})"
)
class Address(Base):
__tablename__ = 'address'
id = Column(Integer, primary_key=True)
email_address = Column(String, nullable=False)
user_id = Column(Integer, ForeignKey('user_account.id'))
user = relationship("User", back_populates="addresses")
def __repr__(self):
return f"Address(id={self.id!r}, email_address={self.email_address!r})"
Base.metadata.create_all(engine)
squidward = User(name="squidward", fullname="Squidward Tentacles")
krabs = User(name="ehkrabs", fullname="Eugene H. Krabs")
session = Session(engine)
session.add(squidward)
session.add(krabs)
session.commit()
u1 = User(name='pkrabs', fullname='Pearl Krabs')
a1 = Address(email_address="pearl.krabs@gmail.com")
u1.addresses.append(a1)
a2 = Address(email_address="pearl@aol.com", user=u1)
session.add(u1)
session.add(a1)
session.add(a2)
session.commit()
# with engine.connect() as conn:
# conn.execute(text("CREATE TABLE some_table (x int, y int)"))
# conn.execute(
# text("INSERT INTO some_table (x, y) VALUES (:x, :y)"),
# [{"x": 1, "y": 1}, {"x": 2, "y": 4}]
# )
# conn.commit()
#
# with engine.begin() as conn:
# conn.execute(
# text("INSERT INTO some_table (x, y) VALUES (:x, :y)"),
# [{"x": 6, "y": 8}, {"x": 9, "y": 10}]
# )
#
# # with engine.connect() as conn:
# # result = conn.execute(text("SELECT x, y FROM some_table"))
# # for row in result:
# # print(f"x: {row.x} y: {row.y}")
#
#
# stmt = text(
# "SELECT x, y FROM some_table WHERE y > :y ORDER BY x, y").bindparams(y=6)
#
# with Session(engine) as session:
# result = session.execute(stmt)
# for row in result:
# print(f"x: {row.x} y: {row.y}")

153
poetry.lock generated
View File

@ -73,17 +73,6 @@ category = "dev"
optional = false optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
[[package]]
name = "commonmark"
version = "0.9.1"
description = "Python parser for the CommonMark Markdown spec"
category = "main"
optional = false
python-versions = "*"
[package.extras]
test = ["hypothesis (==3.55.3)", "flake8 (==3.7.8)"]
[[package]] [[package]]
name = "decorator" name = "decorator"
version = "5.1.1" version = "5.1.1"
@ -369,38 +358,11 @@ category = "main"
optional = false optional = false
python-versions = "*" python-versions = "*"
[[package]]
name = "pydub-stubs"
version = "0.25.1.0"
description = "Stub-only package containing type information for pydub"
category = "dev"
optional = false
python-versions = ">=3.8,<4.0"
[[package]]
name = "pydymenu"
version = "0.5.2"
description = "A pythonic wrapper interface for fzf, dmenu, and rofi."
category = "main"
optional = false
python-versions = "*"
[package.dependencies]
rich = "*"
[[package]]
name = "pyfzf"
version = "0.3.1"
description = "Python wrapper for junegunn's fuzzyfinder (fzf)"
category = "main"
optional = false
python-versions = "*"
[[package]] [[package]]
name = "pygments" name = "pygments"
version = "2.11.2" version = "2.11.2"
description = "Pygments is a syntax highlighting package written in Python." description = "Pygments is a syntax highlighting package written in Python."
category = "main" category = "dev"
optional = false optional = false
python-versions = ">=3.5" python-versions = ">=3.5"
@ -511,28 +473,6 @@ pytest = ">=3.0.0"
dev = ["pre-commit", "tox"] dev = ["pre-commit", "tox"]
doc = ["sphinx", "sphinx-rtd-theme"] doc = ["sphinx", "sphinx-rtd-theme"]
[[package]]
name = "python-levenshtein"
version = "0.12.2"
description = "Python extension for computing string edit distances and similarities."
category = "main"
optional = false
python-versions = "*"
[[package]]
name = "python-slugify"
version = "6.1.2"
description = "A Python slugify application that also handles Unicode"
category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*"
[package.dependencies]
text-unidecode = ">=1.3"
[package.extras]
unidecode = ["Unidecode (>=1.1.1)"]
[[package]] [[package]]
name = "python-vlc" name = "python-vlc"
version = "3.0.16120" version = "3.0.16120"
@ -541,21 +481,6 @@ category = "main"
optional = false optional = false
python-versions = "*" python-versions = "*"
[[package]]
name = "rich"
version = "12.5.1"
description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal"
category = "main"
optional = false
python-versions = ">=3.6.3,<4.0.0"
[package.dependencies]
commonmark = ">=0.9.0,<0.10.0"
pygments = ">=2.6.0,<3.0.0"
[package.extras]
jupyter = ["ipywidgets (>=7.5.1,<8.0.0)"]
[[package]] [[package]]
name = "six" name = "six"
version = "1.16.0" version = "1.16.0"
@ -566,7 +491,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
[[package]] [[package]]
name = "sqlalchemy" name = "sqlalchemy"
version = "1.4.40" version = "1.4.32"
description = "Database Abstraction Library" description = "Database Abstraction Library"
category = "main" category = "main"
optional = false optional = false
@ -579,8 +504,8 @@ greenlet = {version = "!=0.4.17", markers = "python_version >= \"3\" and (platfo
aiomysql = ["greenlet (!=0.4.17)", "aiomysql"] aiomysql = ["greenlet (!=0.4.17)", "aiomysql"]
aiosqlite = ["typing_extensions (!=3.10.0.1)", "greenlet (!=0.4.17)", "aiosqlite"] aiosqlite = ["typing_extensions (!=3.10.0.1)", "greenlet (!=0.4.17)", "aiosqlite"]
asyncio = ["greenlet (!=0.4.17)"] asyncio = ["greenlet (!=0.4.17)"]
asyncmy = ["greenlet (!=0.4.17)", "asyncmy (>=0.2.3,!=0.2.4)"] asyncmy = ["greenlet (!=0.4.17)", "asyncmy (>=0.2.3)"]
mariadb_connector = ["mariadb (>=1.0.1,!=1.1.2)"] mariadb_connector = ["mariadb (>=1.0.1)"]
mssql = ["pyodbc"] mssql = ["pyodbc"]
mssql_pymssql = ["pymssql"] mssql_pymssql = ["pymssql"]
mssql_pyodbc = ["pyodbc"] mssql_pyodbc = ["pyodbc"]
@ -590,7 +515,7 @@ mysql_connector = ["mysql-connector-python"]
oracle = ["cx_oracle (>=7,<8)", "cx_oracle (>=7)"] oracle = ["cx_oracle (>=7,<8)", "cx_oracle (>=7)"]
postgresql = ["psycopg2 (>=2.7)"] postgresql = ["psycopg2 (>=2.7)"]
postgresql_asyncpg = ["greenlet (!=0.4.17)", "asyncpg"] postgresql_asyncpg = ["greenlet (!=0.4.17)", "asyncpg"]
postgresql_pg8000 = ["pg8000 (>=1.16.6,!=1.29.0)"] postgresql_pg8000 = ["pg8000 (>=1.16.6)"]
postgresql_psycopg2binary = ["psycopg2-binary"] postgresql_psycopg2binary = ["psycopg2-binary"]
postgresql_psycopg2cffi = ["psycopg2cffi"] postgresql_psycopg2cffi = ["psycopg2cffi"]
pymysql = ["pymysql (<1)", "pymysql"] pymysql = ["pymysql (<1)", "pymysql"]
@ -624,25 +549,6 @@ pure-eval = "*"
[package.extras] [package.extras]
tests = ["pytest", "typeguard", "pygments", "littleutils", "cython"] tests = ["pytest", "typeguard", "pygments", "littleutils", "cython"]
[[package]]
name = "text-unidecode"
version = "1.3"
description = "The most basic Text::Unidecode port"
category = "main"
optional = false
python-versions = "*"
[[package]]
name = "thefuzz"
version = "0.19.0"
description = "Fuzzy string matching in python"
category = "main"
optional = false
python-versions = "*"
[package.extras]
speedup = ["python-levenshtein (>=0.12)"]
[[package]] [[package]]
name = "tinytag" name = "tinytag"
version = "1.8.1" version = "1.8.1"
@ -708,7 +614,7 @@ python-versions = "*"
[metadata] [metadata]
lock-version = "1.1" lock-version = "1.1"
python-versions = "^3.9" python-versions = "^3.9"
content-hash = "91e055875df86707e1ce1544b1d29126265011d750897912daa37af3fe005498" content-hash = "32b91fc8cb421cc92689db4fc1a4647044714d6e2a72194fe7caf2e25c821b55"
[metadata.files] [metadata.files]
alembic = [ alembic = [
@ -739,7 +645,6 @@ colorama = [
{file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"},
{file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"},
] ]
commonmark = []
decorator = [ decorator = [
{file = "decorator-5.1.1-py3-none-any.whl", hash = "sha256:b8c3f85900b9dc423225913c5aace94729fe1fa9763b38939a95226f02d37186"}, {file = "decorator-5.1.1-py3-none-any.whl", hash = "sha256:b8c3f85900b9dc423225913c5aace94729fe1fa9763b38939a95226f02d37186"},
{file = "decorator-5.1.1.tar.gz", hash = "sha256:637996211036b6385ef91435e4fae22989472f9d571faba8927ba8253acbc330"}, {file = "decorator-5.1.1.tar.gz", hash = "sha256:637996211036b6385ef91435e4fae22989472f9d571faba8927ba8253acbc330"},
@ -981,9 +886,6 @@ pydub = [
{file = "pydub-0.25.1-py2.py3-none-any.whl", hash = "sha256:65617e33033874b59d87db603aa1ed450633288aefead953b30bded59cb599a6"}, {file = "pydub-0.25.1-py2.py3-none-any.whl", hash = "sha256:65617e33033874b59d87db603aa1ed450633288aefead953b30bded59cb599a6"},
{file = "pydub-0.25.1.tar.gz", hash = "sha256:980a33ce9949cab2a569606b65674d748ecbca4f0796887fd6f46173a7b0d30f"}, {file = "pydub-0.25.1.tar.gz", hash = "sha256:980a33ce9949cab2a569606b65674d748ecbca4f0796887fd6f46173a7b0d30f"},
] ]
pydub-stubs = []
pydymenu = []
pyfzf = []
pygments = [ pygments = [
{file = "Pygments-2.11.2-py3-none-any.whl", hash = "sha256:44238f1b60a76d78fc8ca0528ee429702aae011c265fe6a8dd8b63049ae41c65"}, {file = "Pygments-2.11.2-py3-none-any.whl", hash = "sha256:44238f1b60a76d78fc8ca0528ee429702aae011c265fe6a8dd8b63049ae41c65"},
{file = "Pygments-2.11.2.tar.gz", hash = "sha256:4e426f72023d88d03b2fa258de560726ce890ff3b630f88c21cbb8b2503b8c6a"}, {file = "Pygments-2.11.2.tar.gz", hash = "sha256:4e426f72023d88d03b2fa258de560726ce890ff3b630f88c21cbb8b2503b8c6a"},
@ -1053,18 +955,51 @@ pytest-qt = [
{file = "pytest-qt-4.0.2.tar.gz", hash = "sha256:dfc5240dec7eb43b76bcb5f9a87eecae6ef83592af49f3af5f1d5d093acaa93e"}, {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"}, {file = "pytest_qt-4.0.2-py2.py3-none-any.whl", hash = "sha256:e03847ac02a890ccaac0fde1748855b9dce425aceba62005c6cfced6cf7d5456"},
] ]
python-levenshtein = []
python-slugify = []
python-vlc = [ python-vlc = [
{file = "python-vlc-3.0.16120.tar.gz", hash = "sha256:92f98fee088f72bd6d063b3b3312d0bd29b37e7ad65ddeb3a7303320300c2807"}, {file = "python-vlc-3.0.16120.tar.gz", hash = "sha256:92f98fee088f72bd6d063b3b3312d0bd29b37e7ad65ddeb3a7303320300c2807"},
{file = "python_vlc-3.0.16120-py3-none-any.whl", hash = "sha256:c409afb38fe9f788a663b4302ca583f31289ef0860ab2b1668da96bbe8f14bfc"}, {file = "python_vlc-3.0.16120-py3-none-any.whl", hash = "sha256:c409afb38fe9f788a663b4302ca583f31289ef0860ab2b1668da96bbe8f14bfc"},
] ]
rich = []
six = [ six = [
{file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"},
{file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
] ]
sqlalchemy = [] sqlalchemy = [
{file = "SQLAlchemy-1.4.32-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:4b2bcab3a914715d332ca783e9bda13bc570d8b9ef087563210ba63082c18c16"},
{file = "SQLAlchemy-1.4.32-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:159c2f69dd6efd28e894f261ffca1100690f28210f34cfcd70b895e0ea7a64f3"},
{file = "SQLAlchemy-1.4.32-cp27-cp27m-win_amd64.whl", hash = "sha256:d7e483f4791fbda60e23926b098702340504f7684ce7e1fd2c1bf02029288423"},
{file = "SQLAlchemy-1.4.32-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:4aa96e957141006181ca58e792e900ee511085b8dae06c2d08c00f108280fb8a"},
{file = "SQLAlchemy-1.4.32-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:576684771456d02e24078047c2567025f2011977aa342063468577d94e194b00"},
{file = "SQLAlchemy-1.4.32-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff677fa4522dafb5a5e2c0cf909790d5d367326321aeabc0dffc9047cb235bd"},
{file = "SQLAlchemy-1.4.32-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:8679f9aba5ac22e7bce54ccd8a77641d3aea3e2d96e73e4356c887ebf8ff1082"},
{file = "SQLAlchemy-1.4.32-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c7046f7aa2db445daccc8424f50b47a66c4039c9f058246b43796aa818f8b751"},
{file = "SQLAlchemy-1.4.32-cp310-cp310-win32.whl", hash = "sha256:bedd89c34ab62565d44745212814e4b57ef1c24ad4af9b29c504ce40f0dc6558"},
{file = "SQLAlchemy-1.4.32-cp310-cp310-win_amd64.whl", hash = "sha256:199dc6d0068753b6a8c0bd3aceb86a3e782df118260ebc1fa981ea31ee054674"},
{file = "SQLAlchemy-1.4.32-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:8e1e5d96b744a4f91163290b01045430f3f32579e46d87282449e5b14d27d4ac"},
{file = "SQLAlchemy-1.4.32-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:edfcf93fd92e2f9eef640b3a7a40db20fe3c1d7c2c74faa41424c63dead61b76"},
{file = "SQLAlchemy-1.4.32-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:04164e0063feb7aedd9d073db0fd496edb244be40d46ea1f0d8990815e4b8c34"},
{file = "SQLAlchemy-1.4.32-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5ba59761c19b800bc2e1c9324da04d35ef51e4ee9621ff37534bc2290d258f71"},
{file = "SQLAlchemy-1.4.32-cp36-cp36m-win32.whl", hash = "sha256:708973b5d9e1e441188124aaf13c121e5b03b6054c2df59b32219175a25aa13e"},
{file = "SQLAlchemy-1.4.32-cp36-cp36m-win_amd64.whl", hash = "sha256:316270e5867566376e69a0ac738b863d41396e2b63274616817e1d34156dff0e"},
{file = "SQLAlchemy-1.4.32-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:9a0195af6b9050c9322a97cf07514f66fe511968e623ca87b2df5e3cf6349615"},
{file = "SQLAlchemy-1.4.32-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7e4a3c0c3c596296b37f8427c467c8e4336dc8d50f8ed38042e8ba79507b2c9"},
{file = "SQLAlchemy-1.4.32-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:bca714d831e5b8860c3ab134c93aec63d1a4f493bed20084f54e3ce9f0a3bf99"},
{file = "SQLAlchemy-1.4.32-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e9a680d9665f88346ed339888781f5236347933906c5a56348abb8261282ec48"},
{file = "SQLAlchemy-1.4.32-cp37-cp37m-win32.whl", hash = "sha256:9cb5698c896fa72f88e7ef04ef62572faf56809093180771d9be8d9f2e264a13"},
{file = "SQLAlchemy-1.4.32-cp37-cp37m-win_amd64.whl", hash = "sha256:8b9a395122770a6f08ebfd0321546d7379f43505882c7419d7886856a07caa13"},
{file = "SQLAlchemy-1.4.32-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:3f88a4ee192142eeed3fe173f673ea6ab1f5a863810a9d85dbf6c67a9bd08f97"},
{file = "SQLAlchemy-1.4.32-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dd93162615870c976dba43963a24bb418b28448fef584f30755990c134a06a55"},
{file = "SQLAlchemy-1.4.32-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:5a2e73508f939175363d8a4be9dcdc84cf16a92578d7fa86e6e4ca0e6b3667b2"},
{file = "SQLAlchemy-1.4.32-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bfec934aac7f9fa95fc82147a4ba5db0a8bdc4ebf1e33b585ab8860beb10232f"},
{file = "SQLAlchemy-1.4.32-cp38-cp38-win32.whl", hash = "sha256:bb42f9b259c33662c6a9b866012f6908a91731a419e69304e1261ba3ab87b8d1"},
{file = "SQLAlchemy-1.4.32-cp38-cp38-win_amd64.whl", hash = "sha256:7ff72b3cc9242d1a1c9b84bd945907bf174d74fc2519efe6184d6390a8df478b"},
{file = "SQLAlchemy-1.4.32-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:5dc9801ae9884e822ba942ca493642fb50f049c06b6dbe3178691fce48ceb089"},
{file = "SQLAlchemy-1.4.32-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4607d2d16330757818c9d6fba322c2e80b4b112ff24295d1343a80b876eb0ed"},
{file = "SQLAlchemy-1.4.32-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:20e9eba7fd86ef52e0df25bea83b8b518dfdf0bce09b336cfe51671f52aaaa3f"},
{file = "SQLAlchemy-1.4.32-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:290cbdf19129ae520d4bdce392648c6fcdbee763bc8f750b53a5ab51880cb9c9"},
{file = "SQLAlchemy-1.4.32-cp39-cp39-win32.whl", hash = "sha256:1bbac3e8293b34c4403d297e21e8f10d2a57756b75cff101dc62186adec725f5"},
{file = "SQLAlchemy-1.4.32-cp39-cp39-win_amd64.whl", hash = "sha256:b3f1d9b3aa09ab9adc7f8c4b40fc3e081eb903054c9a6f9ae1633fe15ae503b4"},
{file = "SQLAlchemy-1.4.32.tar.gz", hash = "sha256:6fdd2dc5931daab778c2b65b03df6ae68376e028a3098eb624d0909d999885bc"},
]
sqlalchemy-stubs = [ sqlalchemy-stubs = [
{file = "sqlalchemy-stubs-0.4.tar.gz", hash = "sha256:c665d6dd4482ef642f01027fa06c3d5e91befabb219dc71fc2a09e7d7695f7ae"}, {file = "sqlalchemy-stubs-0.4.tar.gz", hash = "sha256:c665d6dd4482ef642f01027fa06c3d5e91befabb219dc71fc2a09e7d7695f7ae"},
{file = "sqlalchemy_stubs-0.4-py3-none-any.whl", hash = "sha256:5eec7aa110adf9b957b631799a72fef396b23ff99fe296df726645d01e312aa5"}, {file = "sqlalchemy_stubs-0.4-py3-none-any.whl", hash = "sha256:5eec7aa110adf9b957b631799a72fef396b23ff99fe296df726645d01e312aa5"},
@ -1073,8 +1008,6 @@ stack-data = [
{file = "stack_data-0.2.0-py3-none-any.whl", hash = "sha256:999762f9c3132308789affa03e9271bbbe947bf78311851f4d485d8402ed858e"}, {file = "stack_data-0.2.0-py3-none-any.whl", hash = "sha256:999762f9c3132308789affa03e9271bbbe947bf78311851f4d485d8402ed858e"},
{file = "stack_data-0.2.0.tar.gz", hash = "sha256:45692d41bd633a9503a5195552df22b583caf16f0b27c4e58c98d88c8b648e12"}, {file = "stack_data-0.2.0.tar.gz", hash = "sha256:45692d41bd633a9503a5195552df22b583caf16f0b27c4e58c98d88c8b648e12"},
] ]
text-unidecode = []
thefuzz = []
tinytag = [ tinytag = [
{file = "tinytag-1.8.1.tar.gz", hash = "sha256:363ab3107831a5598b68aaa061aba915fb1c7b4254d770232e65d5db8487636d"}, {file = "tinytag-1.8.1.tar.gz", hash = "sha256:363ab3107831a5598b68aaa061aba915fb1c7b4254d770232e65d5db8487636d"},
] ]

View File

@ -18,11 +18,6 @@ PyQtWebEngine = "^5.15.5"
pydub = "^0.25.1" pydub = "^0.25.1"
PyQt5-sip = "^12.9.1" PyQt5-sip = "^12.9.1"
types-psutil = "^5.8.22" types-psutil = "^5.8.22"
python-slugify = "^6.1.2"
thefuzz = "^0.19.0"
python-Levenshtein = "^0.12.2"
pyfzf = "^0.3.1"
pydymenu = "^0.5.2"
[tool.poetry.dev-dependencies] [tool.poetry.dev-dependencies]
ipdb = "^0.13.9" ipdb = "^0.13.9"
@ -31,7 +26,6 @@ PyQt5-stubs = "^5.15.2"
mypy = "^0.931" mypy = "^0.931"
pytest = "^7.0.1" pytest = "^7.0.1"
pytest-qt = "^4.0.2" pytest-qt = "^4.0.2"
pydub-stubs = "^0.25.1"
[build-system] [build-system]
requires = ["poetry-core>=1.0.0"] requires = ["poetry-core>=1.0.0"]

View File

@ -366,6 +366,15 @@ def test_tracks_get_all_tracks(session):
assert track2_path in [a.path for a in result] assert track2_path in [a.path for a in result]
def test_tracks_get_or_create(session):
track1_path = "/a/b/c"
track1 = Tracks.get_or_create(session, track1_path)
assert track1.path == track1_path
track2 = Tracks.get_or_create(session, track1_path)
assert track1 is track2
def test_tracks_by_filename(session): def test_tracks_by_filename(session):
track1_path = "/a/b/c" track1_path = "/a/b/c"