# Standard library imports import datetime as dt from email.message import EmailMessage from typing import Optional import os import re import shutil import smtplib import ssl import tempfile # PyQt imports from PyQt6.QtWidgets import QInputDialog, QMainWindow, QMessageBox, QWidget # Third party imports from mutagen.flac import FLAC # type: ignore from mutagen.mp3 import MP3 # type: ignore from pydub import AudioSegment, effects from pydub.utils import mediainfo from tinytag import TinyTag, TinyTagException # type: ignore # App imports from classes import AudioMetadata, ApplicationError, Tags, TrackDTO from config import Config from log import log from models import Tracks start_time_re = re.compile(r"@\d\d:\d\d") def ask_yes_no( title: str, question: str, default_yes: bool = False, parent: Optional[QMainWindow] = None, ) -> bool: """Ask question; return True for yes, False for no""" dlg = QMessageBox(parent) dlg.setWindowTitle(title) dlg.setText(question) dlg.setStandardButtons( QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No ) dlg.setIcon(QMessageBox.Icon.Question) if default_yes: dlg.setDefaultButton(QMessageBox.StandardButton.Yes) button = dlg.exec() return button == QMessageBox.StandardButton.Yes def fade_point( audio_segment: AudioSegment, fade_threshold: float = 0.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 = segment_length - chunk_size max_vol = audio_segment.dBFS if fade_threshold == 0: fade_threshold = max_vol while ( audio_segment[trim_ms : trim_ms + chunk_size].dBFS < fade_threshold and trim_ms > 0 ): # noqa W503 trim_ms -= chunk_size # if there is no trailing silence, return lenght of track (it's less # the chunk_size, but for chunk_size = 10ms, this may be ignored) return int(trim_ms) def file_is_unreadable(path: Optional[str]) -> bool: """ Returns True if passed path is readable, else False """ if not path: return True return not os.access(path, os.R_OK) 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") # type: ignore except AttributeError: return None return None def get_embedded_time(text: str) -> Optional[dt.datetime]: """Return datetime specified as @hh:mm in text""" try: match = start_time_re.search(text) except TypeError: return None if not match: return None try: return dt.datetime.strptime(match.group(0)[1:], Config.NOTE_TIME_FORMAT) except ValueError: return None def get_all_track_metadata(filepath: str) -> dict[str, str | int | float]: """Return all track metadata""" return ( get_audio_metadata(filepath)._asdict() | get_tags(filepath)._asdict() | dict(path=filepath) ) def get_audio_metadata(filepath: str) -> AudioMetadata: """Return audio metadata""" # Set start_gap, fade_at and silence_at audio = get_audio_segment(filepath) if not audio: return AudioMetadata() else: return AudioMetadata( start_gap=leading_silence(audio), fade_at=int( round(fade_point(audio) / 1000, Config.MILLISECOND_SIGFIGS) * 1000 ), silence_at=int( round(trailing_silence(audio) / 1000, Config.MILLISECOND_SIGFIGS) * 1000 ), ) def get_name(prompt: str, default: str = "") -> str | None: """Get a name from the user""" dlg = QInputDialog() dlg.setInputMode(QInputDialog.InputMode.TextInput) dlg.setLabelText(prompt) while True: if default: dlg.setTextValue(default) dlg.resize(500, 100) ok = dlg.exec() if ok: return dlg.textValue() return None def get_relative_date( past_date: Optional[dt.datetime], now: Optional[dt.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 or past_date == Config.EPOCH: return "Never" if not now: now = dt.datetime.now() # Check parameters if past_date > now: raise ApplicationError("get_relative_date() past_date is after relative_date") delta = now - past_date days = delta.days if days == 0: return "(Today)" elif days == 1: return "(Yesterday)" years, days_remain = divmod(days, 365) months, days_final = divmod(days_remain, 30) parts = [] if years: parts.append(f"{years}y") if months: parts.append(f"{months}m") if days_final: parts.append(f"{days_final}d") formatted = " ".join(parts) return f"({formatted} ago)" def get_tags(path: str) -> Tags: """ Return a dictionary of title, artist, bitrate and duration-in-milliseconds. """ try: tag = TinyTag.get(path) except FileNotFoundError: raise ApplicationError(f"File not found: {path}") except TinyTagException: raise ApplicationError(f"Can't read tags in {path}") if ( tag.title is None or tag.artist is None or tag.bitrate is None or tag.duration is None ): raise ApplicationError(f"Missing tags in {path}") return Tags( title=tag.title, artist=tag.artist, bitrate=round(tag.bitrate), duration=int(round(tag.duration, Config.MILLISECOND_SIGFIGS) * 1000), ) 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 < silence_threshold and trim_ms < len( # noqa W504 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 | None, none: str = "-") -> str: """Convert milliseconds to mm:ss""" if ms is None: return none minutes, seconds = divmod(ms // 1000, 60) return f"{minutes}:{seconds:02d}" def normalise_track(path: str) -> None: """Normalise track""" # Check type ftype = os.path.splitext(path)[1][1:] if ftype not in ["mp3", "flac"]: log.error( f"helpers.normalise_track({path}): " f"File type {ftype} not implemented" ) bitrate = mediainfo(path)["bit_rate"] audio = get_audio_segment(path) if not audio: return # Get current file gid, uid and permissions stats = os.stat(path) try: # Copy original file _, 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:], bitrate=bitrate) # Fix up permssions and ownership os.chown(path, stats.st_uid, stats.st_gid) os.chmod(path, stats.st_mode) # Copy tags tag_handler: type[FLAC | MP3] 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 send_mail(to_addr: str, from_addr: str, subj: str, body: str) -> None: # From https://docs.python.org/3/library/email.examples.html # Create a text/plain message msg = EmailMessage() msg.set_content(body) msg["Subject"] = subj msg["From"] = from_addr msg["To"] = to_addr # Send the message via SMTP server. context = ssl.create_default_context() try: s = smtplib.SMTP(host=Config.MAIL_SERVER, port=Config.MAIL_PORT) if Config.MAIL_USE_TLS: s.starttls(context=context) if Config.MAIL_USERNAME and Config.MAIL_PASSWORD: s.login(Config.MAIL_USERNAME, Config.MAIL_PASSWORD) s.send_message(msg) except Exception as e: print(e) finally: s.quit() def set_track_metadata(track: Tracks) -> None: """Set/update track metadata in database""" audio_metadata = get_audio_metadata(track.path) tags = get_tags(track.path) for audio_key in AudioMetadata._fields: setattr(track, audio_key, getattr(audio_metadata, audio_key)) for tag_key in Tags._fields: setattr(track, tag_key, getattr(tags, tag_key)) def show_OK(title: str, msg: str, parent: Optional[QWidget] = None) -> None: """Display a message to user""" dlg = QMessageBox(parent) dlg.setIcon(QMessageBox.Icon.Information) dlg.setWindowTitle(title) dlg.setText(msg) dlg.setStandardButtons(QMessageBox.StandardButton.Ok) _ = dlg.exec() def show_warning(parent: Optional[QMainWindow], title: str, msg: str) -> None: """Display a warning to user""" QMessageBox.warning(parent, title, msg, buttons=QMessageBox.StandardButton.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)