SQLA2.0 main window displays

This commit is contained in:
Keith Edmunds 2022-07-02 21:47:42 +01:00
parent 8192e79d42
commit ff2f0d576c
9 changed files with 4363 additions and 4298 deletions

2
.envrc
View File

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

View File

@ -34,7 +34,7 @@ class Config(object):
DEFAULT_COLUMN_WIDTH = 200
DEFAULT_IMPORT_DIRECTORY = "/home/kae/Nextcloud/tmp"
DEFAULT_OUTPUT_DIRECTORY = "/home/kae/music/Singles"
DISPLAY_SQL = False
DISPLAY_SQL = True
ERRORS_TO = ['kae@midnighthax.com']
FADE_STEPS = 20
FADE_TIME = 3000

View File

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

View File

@ -1,227 +1,227 @@
import os
import psutil
from config import Config
from datetime import datetime
from pydub import AudioSegment
from PyQt5.QtWidgets import QMessageBox
from tinytag import TinyTag
from typing import Dict, Optional, Union
def ask_yes_no(title: str, question: str) -> bool:
"""Ask question; return True for yes, False for no"""
button_reply: bool = QMessageBox.question(None, title, question)
return button_reply == QMessageBox.Yes
def fade_point(
audio_segment: AudioSegment, fade_threshold: int = 0,
chunk_size: int = Config.AUDIO_SEGMENT_CHUNK_SIZE) -> int:
"""
Returns the millisecond/index of the point where the volume drops below
the maximum and doesn't get louder again.
audio_segment - the sdlg_search_database_uiegment to find silence in
fade_threshold - the upper bound for how quiet is silent in dFBS
chunk_size - chunk size for interating over the segment in ms
"""
assert chunk_size > 0 # to avoid infinite loop
segment_length: int = audio_segment.duration_seconds * 1000 # ms
trim_ms: int = segment_length - chunk_size
max_vol: int = audio_segment.dBFS
if fade_threshold == 0:
fade_threshold = max_vol
while (
audio_segment[trim_ms:trim_ms + chunk_size].dBFS < fade_threshold
and trim_ms > 0): # noqa W503
trim_ms -= chunk_size
# if there is no trailing silence, return lenght of track (it's less
# the chunk_size, but for chunk_size = 10ms, this may be ignored)
return int(trim_ms)
def get_audio_segment(path: str) -> Optional[AudioSegment]:
try:
if path.endswith('.mp3'):
return AudioSegment.from_mp3(path)
elif path.endswith('.flac'):
return AudioSegment.from_file(path, "flac")
except AttributeError:
return None
def get_tags(path: str) -> Dict[str, Union[str, int]]:
"""
Return a dictionary of title, artist, duration-in-milliseconds and path.
"""
tag: TinyTag = TinyTag.get(path)
d = dict(
title=tag.title,
artist=tag.artist,
duration=int(round(tag.duration, Config.MILLISECOND_SIGFIGS) * 1000),
path=path
)
return d
def get_relative_date(past_date: datetime, reference_date: datetime = None) \
-> str:
"""
Return how long before reference_date past_date is as string.
Params:
@past_date: datetime
@reference_date: datetime, defaults to current date and time
@return: string
"""
if not past_date:
return "Never"
if not reference_date:
reference_date = datetime.now()
# Check parameters
if past_date > reference_date:
return "get_relative_date() past_date is after relative_date"
days: int
days_str: str
weeks: int
weeks_str: str
weeks, days = divmod((reference_date.date() - past_date.date()).days, 7)
if weeks == days == 0:
# Played today, so return time instead
return past_date.strftime("%H:%M")
if weeks == 1:
weeks_str = "week"
else:
weeks_str = "weeks"
if days == 1:
days_str = "day"
else:
days_str = "days"
return f"{weeks} {weeks_str}, {days} {days_str} ago"
def leading_silence(
audio_segment: AudioSegment,
silence_threshold: int = Config.DBFS_SILENCE,
chunk_size: int = Config.AUDIO_SEGMENT_CHUNK_SIZE) -> int:
"""
Returns the millisecond/index that the leading silence ends.
audio_segment - the segment to find silence in
silence_threshold - the upper bound for how quiet is silent in dFBS
chunk_size - chunk size for interating over the segment in ms
https://github.com/jiaaro/pydub/blob/master/pydub/silence.py
"""
trim_ms: int = 0 # ms
assert chunk_size > 0 # to avoid infinite loop
while (
audio_segment[trim_ms:trim_ms + chunk_size].dBFS < # noqa W504
silence_threshold and trim_ms < len(audio_segment)):
trim_ms += chunk_size
# if there is no end it should return the length of the segment
return min(trim_ms, len(audio_segment))
def ms_to_mmss(ms: int, decimals: int = 0, negative: bool = False) -> str:
"""Convert milliseconds to mm:ss"""
minutes: int
remainder: int
seconds: float
if not ms:
return "-"
sign = ""
if ms < 0:
if negative:
sign = "-"
else:
ms = 0
minutes, remainder = divmod(ms, 60 * 1000)
seconds = remainder / 1000
# if seconds >= 59.5, it will be represented as 60, which looks odd.
# So, fake it under those circumstances
if seconds >= 59.5:
seconds = 59.0
return f"{sign}{minutes:.0f}:{seconds:02.{decimals}f}"
def open_in_audacity(path: str) -> Optional[bool]:
"""
Open passed file in Audacity
Return True if apparently opened successfully, else False
"""
# Return if audacity not running
if "audacity" not in [i.name() for i in psutil.process_iter()]:
return False
# Return if path not given
if not path:
return False
to_pipe: str = '/tmp/audacity_script_pipe.to.' + str(os.getuid())
from_pipe: str = '/tmp/audacity_script_pipe.from.' + str(os.getuid())
eol: str = '\n'
def send_command(command: str) -> None:
"""Send a single command."""
to_audacity.write(command + eol)
to_audacity.flush()
def get_response() -> str:
"""Return the command response."""
result: str = ''
line: str = ''
while True:
result += line
line = from_audacity.readline()
if line == '\n' and len(result) > 0:
break
return result
def do_command(command: str) -> str:
"""Send one command, and return the response."""
send_command(command)
response = get_response()
return response
with open(to_pipe, 'w') as to_audacity, open(
from_pipe, 'rt') as from_audacity:
do_command(f'Import2: Filename="{path}"')
def show_warning(title: str, msg: str) -> None:
"""Display a warning to user"""
QMessageBox.warning(None, title, msg, buttons=QMessageBox.Cancel)
def trailing_silence(
audio_segment: AudioSegment, silence_threshold: int = -50,
chunk_size: int = Config.AUDIO_SEGMENT_CHUNK_SIZE) -> int:
"""Return fade point from start in milliseconds"""
return fade_point(audio_segment, silence_threshold, chunk_size)
# import os
# import psutil
#
# from config import Config
# from datetime import datetime
# from pydub import AudioSegment
# from PyQt5.QtWidgets import QMessageBox
# from tinytag import TinyTag
# from typing import Dict, Optional, Union
#
#
# def ask_yes_no(title: str, question: str) -> bool:
# """Ask question; return True for yes, False for no"""
#
# button_reply: bool = QMessageBox.question(None, title, question)
#
# return button_reply == QMessageBox.Yes
#
#
# def fade_point(
# audio_segment: AudioSegment, fade_threshold: int = 0,
# chunk_size: int = Config.AUDIO_SEGMENT_CHUNK_SIZE) -> int:
# """
# Returns the millisecond/index of the point where the volume drops below
# the maximum and doesn't get louder again.
# audio_segment - the sdlg_search_database_uiegment to find silence in
# fade_threshold - the upper bound for how quiet is silent in dFBS
# chunk_size - chunk size for interating over the segment in ms
# """
#
# assert chunk_size > 0 # to avoid infinite loop
#
# segment_length: int = audio_segment.duration_seconds * 1000 # ms
# trim_ms: int = segment_length - chunk_size
# max_vol: int = audio_segment.dBFS
# if fade_threshold == 0:
# fade_threshold = max_vol
#
# while (
# audio_segment[trim_ms:trim_ms + chunk_size].dBFS < fade_threshold
# and trim_ms > 0): # noqa W503
# trim_ms -= chunk_size
#
# # if there is no trailing silence, return lenght of track (it's less
# # the chunk_size, but for chunk_size = 10ms, this may be ignored)
# return int(trim_ms)
#
#
# def get_audio_segment(path: str) -> Optional[AudioSegment]:
# try:
# if path.endswith('.mp3'):
# return AudioSegment.from_mp3(path)
# elif path.endswith('.flac'):
# return AudioSegment.from_file(path, "flac")
# except AttributeError:
# return None
#
#
# def get_tags(path: str) -> Dict[str, Union[str, int]]:
# """
# Return a dictionary of title, artist, duration-in-milliseconds and path.
# """
#
# tag: TinyTag = TinyTag.get(path)
#
# d = dict(
# title=tag.title,
# artist=tag.artist,
# duration=int(round(tag.duration, Config.MILLISECOND_SIGFIGS) * 1000),
# path=path
# )
# return d
#
#
# def get_relative_date(past_date: datetime, reference_date: datetime = None) \
# -> str:
# """
# Return how long before reference_date past_date is as string.
#
# Params:
# @past_date: datetime
# @reference_date: datetime, defaults to current date and time
#
# @return: string
# """
#
# if not past_date:
# return "Never"
# if not reference_date:
# reference_date = datetime.now()
#
# # Check parameters
# if past_date > reference_date:
# return "get_relative_date() past_date is after relative_date"
#
# days: int
# days_str: str
# weeks: int
# weeks_str: str
#
# weeks, days = divmod((reference_date.date() - past_date.date()).days, 7)
# if weeks == days == 0:
# # Played today, so return time instead
# return past_date.strftime("%H:%M")
# if weeks == 1:
# weeks_str = "week"
# else:
# weeks_str = "weeks"
# if days == 1:
# days_str = "day"
# else:
# days_str = "days"
# return f"{weeks} {weeks_str}, {days} {days_str} ago"
#
#
# def leading_silence(
# audio_segment: AudioSegment,
# silence_threshold: int = Config.DBFS_SILENCE,
# chunk_size: int = Config.AUDIO_SEGMENT_CHUNK_SIZE) -> int:
# """
# Returns the millisecond/index that the leading silence ends.
# audio_segment - the segment to find silence in
# silence_threshold - the upper bound for how quiet is silent in dFBS
# chunk_size - chunk size for interating over the segment in ms
#
# https://github.com/jiaaro/pydub/blob/master/pydub/silence.py
# """
#
# trim_ms: int = 0 # ms
# assert chunk_size > 0 # to avoid infinite loop
# while (
# audio_segment[trim_ms:trim_ms + chunk_size].dBFS < # noqa W504
# silence_threshold and trim_ms < len(audio_segment)):
# trim_ms += chunk_size
#
# # if there is no end it should return the length of the segment
# return min(trim_ms, len(audio_segment))
#
#
# def ms_to_mmss(ms: int, decimals: int = 0, negative: bool = False) -> str:
# """Convert milliseconds to mm:ss"""
#
# minutes: int
# remainder: int
# seconds: float
#
# if not ms:
# return "-"
# sign = ""
# if ms < 0:
# if negative:
# sign = "-"
# else:
# ms = 0
#
# minutes, remainder = divmod(ms, 60 * 1000)
# seconds = remainder / 1000
#
# # if seconds >= 59.5, it will be represented as 60, which looks odd.
# # So, fake it under those circumstances
# if seconds >= 59.5:
# seconds = 59.0
#
# return f"{sign}{minutes:.0f}:{seconds:02.{decimals}f}"
#
#
# def open_in_audacity(path: str) -> Optional[bool]:
# """
# Open passed file in Audacity
#
# Return True if apparently opened successfully, else False
# """
#
# # Return if audacity not running
# if "audacity" not in [i.name() for i in psutil.process_iter()]:
# return False
#
# # Return if path not given
# if not path:
# return False
#
# to_pipe: str = '/tmp/audacity_script_pipe.to.' + str(os.getuid())
# from_pipe: str = '/tmp/audacity_script_pipe.from.' + str(os.getuid())
# eol: str = '\n'
#
# def send_command(command: str) -> None:
# """Send a single command."""
# to_audacity.write(command + eol)
# to_audacity.flush()
#
# def get_response() -> str:
# """Return the command response."""
#
# result: str = ''
# line: str = ''
#
# while True:
# result += line
# line = from_audacity.readline()
# if line == '\n' and len(result) > 0:
# break
# return result
#
# def do_command(command: str) -> str:
# """Send one command, and return the response."""
#
# send_command(command)
# response = get_response()
# return response
#
# with open(to_pipe, 'w') as to_audacity, open(
# from_pipe, 'rt') as from_audacity:
# do_command(f'Import2: Filename="{path}"')
#
#
# def show_warning(title: str, msg: str) -> None:
# """Display a warning to user"""
#
# QMessageBox.warning(None, title, msg, buttons=QMessageBox.Cancel)
#
#
# def trailing_silence(
# audio_segment: AudioSegment, silence_threshold: int = -50,
# chunk_size: int = Config.AUDIO_SEGMENT_CHUNK_SIZE) -> int:
# """Return fade point from start in milliseconds"""
#
# return fade_point(audio_segment, silence_threshold, chunk_size)

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,292 +1,292 @@
#!/usr/bin/env python
import argparse
import os
import shutil
import tempfile
import helpers
from config import Config
from helpers import (
fade_point,
get_audio_segment,
get_tags,
leading_silence,
trailing_silence,
)
from log import DEBUG, INFO
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 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, or update database entry
if path already in database.
Return track.
"""
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()
if normalise or normalise is None and Config.NORMALISE_ON_IMPORT:
# 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 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):
"""
Repopulate database
"""
# 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 = []
for root, dirs, files in os.walk(Config.ROOT):
for f in files:
path = os.path.join(root, f)
ext = os.path.splitext(f)[1]
if ext in [".flac", ".mp3"]:
os_paths_list.append(path)
os_paths = set(os_paths_list)
# Find any files in music directory that are not in database
files_not_in_db = list(os_paths - db_paths)
# Find paths in database missing in music directory
paths_not_found = []
missing_file_count = 0
more_files_to_report = False
for path in list(db_paths - os_paths):
if missing_file_count >= Config.MAX_MISSING_FILES_TO_REPORT:
more_files_to_report = True
break
missing_file_count += 1
track = Tracks.get_by_path(session, path)
if not track:
ERROR(f"update_db: {path} not found in db")
continue
paths_not_found.append(track)
# Output messages (so if running via cron, these will get sent to
# user)
if files_not_in_db:
print("Files in music directory but not in database")
print("--------------------------------------------")
print("\n".join(files_not_in_db))
print("\n")
if paths_not_found:
print("Invalid paths in database")
print("-------------------------")
for t in paths_not_found:
print(f"""
Track ID: {t.id}
Path: {t.path}
Title: {t.title}
Artist: {t.artist}
""")
if more_files_to_report:
print("There were more paths than listed that were not found")
# Spike
# #!/usr/bin/env python
#
# # 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=}")
# import argparse
# import os
# import shutil
# import tempfile
#
# # Remove references from Playdates
# Playdates.remove_track(session, track.id)
# import helpers
# from config import Config
# from helpers import (
# fade_point,
# get_audio_segment,
# get_tags,
# leading_silence,
# trailing_silence,
# )
# from log import DEBUG, INFO
# from models import Notes, Playdates, Session, Tracks
# from mutagen.flac import FLAC
# from mutagen.mp3 import MP3
# from pydub import effects
#
# # 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)
# # Globals (I know)
# messages = []
#
# # Remove Track entry pointing to invalid path
# Tracks.remove_by_path(session, path)
if __name__ == '__main__' and '__file__' in globals():
main()
#
# 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, or update database entry
# if path already in database.
#
# Return track.
# """
#
# 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()
#
# if normalise or normalise is None and Config.NORMALISE_ON_IMPORT:
# # 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 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):
# """
# Repopulate database
# """
#
# # 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 = []
# for root, dirs, files in os.walk(Config.ROOT):
# for f in files:
# path = os.path.join(root, f)
# ext = os.path.splitext(f)[1]
# if ext in [".flac", ".mp3"]:
# os_paths_list.append(path)
# os_paths = set(os_paths_list)
#
# # Find any files in music directory that are not in database
# files_not_in_db = list(os_paths - db_paths)
#
# # Find paths in database missing in music directory
# paths_not_found = []
# missing_file_count = 0
# more_files_to_report = False
# for path in list(db_paths - os_paths):
# if missing_file_count >= Config.MAX_MISSING_FILES_TO_REPORT:
# more_files_to_report = True
# break
#
# missing_file_count += 1
#
# track = Tracks.get_by_path(session, path)
# if not track:
# ERROR(f"update_db: {path} not found in db")
# continue
#
# paths_not_found.append(track)
#
# # Output messages (so if running via cron, these will get sent to
# # user)
# if files_not_in_db:
# print("Files in music directory but not in database")
# print("--------------------------------------------")
# print("\n".join(files_not_in_db))
# print("\n")
# if paths_not_found:
# print("Invalid paths in database")
# print("-------------------------")
# for t in paths_not_found:
# print(f"""
# Track ID: {t.id}
# Path: {t.path}
# Title: {t.title}
# Artist: {t.artist}
# """)
# if more_files_to_report:
# print("There were more paths than listed that were not found")
#
#
# # Spike
# #
# # # 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)
#
# if __name__ == '__main__' and '__file__' in globals():
# main()