Flake8 and Black run on all files

This commit is contained in:
Keith Edmunds 2023-07-09 16:12:21 +01:00
parent f44d6aa25e
commit 986257bef6
28 changed files with 1359 additions and 1291 deletions

13
.flake8 Normal file
View File

@ -0,0 +1,13 @@
[flake8]
max-line-length = 88
select = C,E,F,W,B,B950
extend-ignore = E203, E501
exclude =
.git
app/ui,
__pycache__,
archive,
migrations,
prof,
docs,
app/icons_rc.py

View File

@ -2,14 +2,12 @@
from PyQt6.QtCore import Qt, QEvent, QObject
from PyQt6.QtWidgets import (
QAbstractItemDelegate,
QAbstractItemView,
QApplication,
QMainWindow,
QMessageBox,
QPlainTextEdit,
QStyledItemDelegate,
QStyleOptionViewItem,
QTableWidget,
QTableWidgetItem,
)
@ -33,16 +31,15 @@ class EscapeDelegate(QStyledItemDelegate):
if event.type() == QEvent.Type.KeyPress:
key_event = cast(QKeyEvent, event)
if key_event.key() == Qt.Key.Key_Return:
if key_event.modifiers() == (
Qt.KeyboardModifier.ControlModifier
):
if key_event.modifiers() == (Qt.KeyboardModifier.ControlModifier):
print("save data")
self.commitData.emit(editor)
self.closeEditor.emit(editor)
return True
elif key_event.key() == Qt.Key.Key_Escape:
discard_edits = QMessageBox.question(
self.parent(), "Abandon edit", "Discard changes?")
self.parent(), "Abandon edit", "Discard changes?"
)
if discard_edits == QMessageBox.StandardButton.Yes:
print("abandon edit")
self.closeEditor.emit(editor)
@ -74,7 +71,7 @@ class MainWindow(QMainWindow):
self.table_widget.resizeRowsToContents()
if __name__ == '__main__':
if __name__ == "__main__":
app = QApplication([])
window = MainWindow()
window.show()

View File

@ -2,7 +2,7 @@
import os
from pydub import AudioSegment, effects
from pydub import AudioSegment
# DIR = "/home/kae/git/musicmuster/archive"
DIR = "/home/kae/git/musicmuster"

View File

@ -1,19 +1,18 @@
import inspect
import logging
import os
from config import Config
from contextlib import contextmanager
from sqlalchemy import create_engine
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')
MYSQL_CONNECT = os.environ.get("MM_DB")
if MYSQL_CONNECT is None:
raise ValueError("MYSQL_CONNECT is undefined")
else:
dbname = MYSQL_CONNECT.split('/')[-1]
dbname = MYSQL_CONNECT.split("/")[-1]
log.debug(f"Database: {dbname}")
# MM_ENV = os.environ.get('MM_ENV', 'PRODUCTION')
@ -31,10 +30,10 @@ else:
engine = create_engine(
MYSQL_CONNECT,
encoding='utf-8',
encoding="utf-8",
echo=Config.DISPLAY_SQL,
pool_pre_ping=True,
future=True
future=True,
)
@ -47,8 +46,7 @@ def Session() -> Generator[scoped_session, None, None]:
Session = scoped_session(sessionmaker(bind=engine, future=True))
log.debug(f"SqlA: session acquired [{hex(id(Session))}]")
log.debug(
f"Session acquisition: {file}:{function}:{lineno} "
f"[{hex(id(Session))}]"
f"Session acquisition: {file}:{function}:{lineno} " f"[{hex(id(Session))}]"
)
yield Session
log.debug(f" SqlA: session released [{hex(id(Session))}]")

View File

@ -1,4 +1,3 @@
import numpy as np
import os
import psutil
import shutil
@ -10,13 +9,13 @@ from config import Config
from datetime import datetime
from email.message import EmailMessage
from log import log
from mutagen.flac import FLAC # type: ignore
from mutagen.mp3 import MP3 # type: ignore
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 PyQt6.QtWidgets import QMainWindow, QMessageBox # type: ignore
from tinytag import TinyTag # type: ignore
from typing import Any, Dict, Optional, Union
from PyQt6.QtWidgets import QMainWindow, QMessageBox # type: ignore
from tinytag import TinyTag # type: ignore
from typing import Any, Dict, Optional
def ask_yes_no(title: str, question: str, default_yes: bool = False) -> bool:
@ -26,7 +25,8 @@ def ask_yes_no(title: str, question: str, default_yes: bool = False) -> bool:
dlg.setWindowTitle(title)
dlg.setText(question)
dlg.setStandardButtons(
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
dlg.setIcon(QMessageBox.Icon.Question)
if default_yes:
dlg.setDefaultButton(QMessageBox.StandardButton.Yes)
@ -36,8 +36,10 @@ def ask_yes_no(title: str, question: str, default_yes: bool = False) -> bool:
def fade_point(
audio_segment: AudioSegment, fade_threshold: float = 0.0,
chunk_size: int = Config.AUDIO_SEGMENT_CHUNK_SIZE) -> int:
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.
@ -55,8 +57,9 @@ def fade_point(
fade_threshold = max_vol
while (
audio_segment[trim_ms:trim_ms + chunk_size].dBFS < fade_threshold
and trim_ms > 0): # noqa W503
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
@ -77,10 +80,10 @@ def file_is_unreadable(path: Optional[str]) -> bool:
def get_audio_segment(path: str) -> Optional[AudioSegment]:
try:
if path.endswith('.mp3'):
if path.endswith(".mp3"):
return AudioSegment.from_mp3(path)
elif path.endswith('.flac'):
return AudioSegment.from_file(path, "flac") # type: ignore
elif path.endswith(".flac"):
return AudioSegment.from_file(path, "flac") # type: ignore
except AttributeError:
return None
@ -99,12 +102,13 @@ def get_tags(path: str) -> Dict[str, Any]:
artist=tag.artist,
bitrate=round(tag.bitrate),
duration=int(round(tag.duration, Config.MILLISECOND_SIGFIGS) * 1000),
path=path
path=path,
)
def get_relative_date(past_date: Optional[datetime],
reference_date: Optional[datetime] = None) -> str:
def get_relative_date(
past_date: Optional[datetime], reference_date: Optional[datetime] = None
) -> str:
"""
Return how long before reference_date past_date is as string.
@ -145,9 +149,10 @@ def get_relative_date(past_date: Optional[datetime],
def leading_silence(
audio_segment: AudioSegment,
silence_threshold: int = Config.DBFS_SILENCE,
chunk_size: int = Config.AUDIO_SEGMENT_CHUNK_SIZE) -> int:
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
@ -159,9 +164,11 @@ def leading_silence(
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)):
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
@ -175,9 +182,9 @@ def send_mail(to_addr, from_addr, subj, body):
msg = EmailMessage()
msg.set_content(body)
msg['Subject'] = subj
msg['From'] = from_addr
msg['To'] = to_addr
msg["Subject"] = subj
msg["From"] = from_addr
msg["To"] = to_addr
# Send the message via SMTP server.
context = ssl.create_default_context()
@ -194,8 +201,7 @@ def send_mail(to_addr, from_addr, subj, body):
s.quit()
def ms_to_mmss(ms: Optional[int], decimals: int = 0,
negative: bool = False) -> str:
def ms_to_mmss(ms: Optional[int], decimals: int = 0, negative: bool = False) -> str:
"""Convert milliseconds to mm:ss"""
minutes: int
@ -227,13 +233,12 @@ def normalise_track(path):
# Check type
ftype = os.path.splitext(path)[1][1:]
if ftype not in ['mp3', 'flac']:
if ftype not in ["mp3", "flac"]:
log.info(
f"helpers.normalise_track({path}): "
f"File type {ftype} not implemented"
f"helpers.normalise_track({path}): " f"File type {ftype} not implemented"
)
bitrate = mediainfo(path)['bit_rate']
bitrate = mediainfo(path)["bit_rate"]
audio = get_audio_segment(path)
if not audio:
return
@ -245,23 +250,20 @@ def normalise_track(path):
_, temp_path = tempfile.mkstemp()
shutil.copyfile(path, temp_path)
except Exception as err:
log.debug(
f"helpers.normalise_track({path}): err1: {repr(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)
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
if ftype == 'flac':
if ftype == "flac":
tag_handler = FLAC
elif ftype == 'mp3':
elif ftype == "mp3":
tag_handler = MP3
else:
return
@ -271,9 +273,7 @@ def normalise_track(path):
dst[tag] = src[tag]
dst.save()
except Exception as err:
log.debug(
f"helpers.normalise_track({path}): err2: {repr(err)}"
)
log.debug(f"helpers.normalise_track({path}): err2: {repr(err)}")
# Restore original file
shutil.copyfile(path, temp_path)
finally:
@ -296,9 +296,9 @@ def open_in_audacity(path: str) -> bool:
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'
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."""
@ -308,13 +308,13 @@ def open_in_audacity(path: str) -> bool:
def get_response() -> str:
"""Return the command response."""
result: str = ''
line: str = ''
result: str = ""
line: str = ""
while True:
result += line
line = from_audacity.readline()
if line == '\n' and len(result) > 0:
if line == "\n" and len(result) > 0:
break
return result
@ -325,8 +325,7 @@ def open_in_audacity(path: str) -> bool:
response = get_response()
return response
with open(to_pipe, 'w') as to_audacity, open(
from_pipe, 'rt') as from_audacity:
with open(to_pipe, "w") as to_audacity, open(from_pipe, "rt") as from_audacity:
do_command(f'Import2: Filename="{path}"')
return True
@ -338,18 +337,18 @@ def set_track_metadata(session, track):
t = get_tags(track.path)
audio = get_audio_segment(track.path)
track.title = t['title']
track.artist = t['artist']
track.bitrate = t['bitrate']
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.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()
@ -358,20 +357,20 @@ def set_track_metadata(session, track):
def show_OK(parent: QMainWindow, title: str, msg: str) -> None:
"""Display a message to user"""
QMessageBox.information(parent, title, msg,
buttons=QMessageBox.StandardButton.Ok)
QMessageBox.information(parent, title, msg, buttons=QMessageBox.StandardButton.Ok)
def show_warning(parent: QMainWindow, title: str, msg: str) -> None:
"""Display a warning to user"""
QMessageBox.warning(parent, title, msg,
buttons=QMessageBox.StandardButton.Cancel)
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:
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)

View File

@ -1,9 +1,9 @@
import urllib.parse
from datetime import datetime
from slugify import slugify # type: ignore
from typing import Dict, Optional
from PyQt6.QtCore import QUrl # type: ignore
from slugify import slugify # type: ignore
from typing import Dict
from PyQt6.QtCore import QUrl # type: ignore
from PyQt6.QtWebEngineWidgets import QWebEngineView
from PyQt6.QtWidgets import QTabWidget
from config import Config
@ -47,13 +47,11 @@ class InfoTabs(QTabWidget):
if url in self.tabtitles.values():
self.setCurrentIndex(
list(self.tabtitles.keys())[
list(self.tabtitles.values()).index(url)
]
list(self.tabtitles.keys())[list(self.tabtitles.values()).index(url)]
)
return
short_title = title[:Config.INFO_TAB_TITLE_LENGTH]
short_title = title[: Config.INFO_TAB_TITLE_LENGTH]
if self.count() < Config.MAX_INFO_TABS:
# Create a new tab
@ -63,9 +61,7 @@ class InfoTabs(QTabWidget):
else:
# Reuse oldest widget
widget = min(
self.last_update, key=self.last_update.get # type: ignore
)
widget = min(self.last_update, key=self.last_update.get) # type: ignore
tab_index = self.indexOf(widget)
self.setTabText(tab_index, short_title)

View File

@ -1,13 +1,11 @@
#!/usr/bin/python3
import os.path
import re
import stackprinter # type: ignore
from dbconfig import Session, scoped_session
from dbconfig import scoped_session
from datetime import datetime
from typing import Iterable, List, Optional, Union, ValuesView
from typing import List, Optional
from sqlalchemy.ext.associationproxy import association_proxy
@ -35,21 +33,14 @@ from sqlalchemy.orm.exc import (
from sqlalchemy.exc import (
IntegrityError,
)
from config import Config
from helpers import (
fade_point,
get_audio_segment,
get_tags,
leading_silence,
trailing_silence,
)
from log import log
Base = declarative_base()
# Database classes
class Carts(Base):
__tablename__ = 'carts'
__tablename__ = "carts"
id: int = Column(Integer, primary_key=True, autoincrement=True)
cart_number: int = Column(Integer, nullable=False, unique=True)
@ -64,10 +55,15 @@ class Carts(Base):
f"name={self.name}, path={self.path}>"
)
def __init__(self, session: scoped_session, cart_number: int,
name: Optional[str] = None,
duration: Optional[int] = None, path: Optional[str] = None,
enabled: bool = True) -> None:
def __init__(
self,
session: scoped_session,
cart_number: int,
name: Optional[str] = None,
duration: Optional[int] = None,
path: Optional[str] = None,
enabled: bool = True,
) -> None:
"""Create new cart"""
self.cart_number = cart_number
@ -81,7 +77,7 @@ class Carts(Base):
class NoteColours(Base):
__tablename__ = 'notecolours'
__tablename__ = "notecolours"
id = Column(Integer, primary_key=True, autoincrement=True)
substring = Column(String(256), index=False)
@ -106,11 +102,15 @@ class NoteColours(Base):
if not text:
return None
for rec in session.execute(
for rec in (
session.execute(
select(NoteColours)
.filter(NoteColours.enabled.is_(True))
.order_by(NoteColours.order)
).scalars().all():
)
.scalars()
.all()
):
if rec.is_regex:
flags = re.UNICODE
if not rec.is_casesensitive:
@ -130,11 +130,11 @@ class NoteColours(Base):
class Playdates(Base):
__tablename__ = 'playdates'
__tablename__ = "playdates"
id: int = Column(Integer, primary_key=True, autoincrement=True)
lastplayed = Column(DateTime, index=True, default=None)
track_id = Column(Integer, ForeignKey('tracks.id'))
track_id = Column(Integer, ForeignKey("tracks.id"))
track: "Tracks" = relationship("Tracks", back_populates="playdates")
def __repr__(self) -> str:
@ -152,8 +152,7 @@ class Playdates(Base):
session.commit()
@staticmethod
def last_played(session: scoped_session,
track_id: int) -> Optional[datetime]:
def last_played(session: scoped_session, track_id: int) -> Optional[datetime]:
"""Return datetime track last played or None"""
last_played = session.execute(
@ -169,8 +168,7 @@ class Playdates(Base):
return None
@staticmethod
def played_after(session: scoped_session,
since: datetime) -> List["Playdates"]:
def played_after(session: scoped_session, since: datetime) -> List["Playdates"]:
"""Return a list of Playdates objects since passed time"""
return (
@ -203,7 +201,7 @@ class Playlists(Base):
"PlaylistRows",
back_populates="playlist",
cascade="all, delete-orphan",
order_by="PlaylistRows.plr_rownum"
order_by="PlaylistRows.plr_rownum",
)
def __repr__(self) -> str:
@ -232,11 +230,9 @@ class Playlists(Base):
)
@classmethod
def create_playlist_from_template(cls,
session: scoped_session,
template: "Playlists",
playlist_name: str) \
-> Optional["Playlists"]:
def create_playlist_from_template(
cls, session: scoped_session, template: "Playlists", playlist_name: str
) -> Optional["Playlists"]:
"""Create a new playlist from template"""
playlist = cls(session, playlist_name)
@ -277,9 +273,7 @@ class Playlists(Base):
return (
session.execute(
select(cls)
.filter(cls.is_template.is_(True))
.order_by(cls.name)
select(cls).filter(cls.is_template.is_(True)).order_by(cls.name)
)
.scalars()
.all()
@ -295,7 +289,7 @@ class Playlists(Base):
.filter(
cls.tab.is_(None),
cls.is_template.is_(False),
cls.deleted.is_(False)
cls.deleted.is_(False),
)
.order_by(cls.last_used.desc())
)
@ -310,11 +304,7 @@ class Playlists(Base):
"""
return (
session.execute(
select(cls)
.where(cls.tab.is_not(None))
.order_by(cls.tab)
)
session.execute(select(cls).where(cls.tab.is_not(None)).order_by(cls.tab))
.scalars()
.all()
)
@ -329,15 +319,9 @@ class Playlists(Base):
def move_tab(session: scoped_session, frm: int, to: int) -> None:
"""Move tabs"""
row_frm = session.execute(
select(Playlists)
.filter_by(tab=frm)
).scalar_one()
row_frm = session.execute(select(Playlists).filter_by(tab=frm)).scalar_one()
row_to = session.execute(
select(Playlists)
.filter_by(tab=to)
).scalar_one()
row_to = session.execute(select(Playlists).filter_by(tab=to)).scalar_one()
row_frm.tab = None
row_to.tab = None
@ -354,8 +338,9 @@ class Playlists(Base):
session.flush()
@staticmethod
def save_as_template(session: scoped_session,
playlist_id: int, template_name: str) -> None:
def save_as_template(
session: scoped_session, playlist_id: int, template_name: str
) -> None:
"""Save passed playlist as new template"""
template = Playlists(session, template_name)
@ -369,15 +354,14 @@ class Playlists(Base):
class PlaylistRows(Base):
__tablename__ = 'playlist_rows'
__tablename__ = "playlist_rows"
id: int = Column(Integer, primary_key=True, autoincrement=True)
plr_rownum: int = Column(Integer, nullable=False)
note: str = Column(String(2048), index=False, default="", nullable=False)
playlist_id: int = Column(Integer, ForeignKey('playlists.id'),
nullable=False)
playlist_id: int = Column(Integer, ForeignKey("playlists.id"), nullable=False)
playlist: Playlists = relationship(Playlists, back_populates="rows")
track_id = Column(Integer, ForeignKey('tracks.id'), nullable=True)
track_id = Column(Integer, ForeignKey("tracks.id"), nullable=True)
track: "Tracks" = relationship("Tracks", back_populates="playlistrows")
played: bool = Column(Boolean, nullable=False, index=False, default=False)
@ -388,13 +372,14 @@ class PlaylistRows(Base):
f"note={self.note}, plr_rownum={self.plr_rownum}>"
)
def __init__(self,
session: scoped_session,
playlist_id: int,
track_id: Optional[int],
row_number: int,
note: str = ""
) -> None:
def __init__(
self,
session: scoped_session,
playlist_id: int,
track_id: Optional[int],
row_number: int,
note: str = "",
) -> None:
"""Create PlaylistRows object"""
self.playlist_id = playlist_id
@ -409,38 +394,38 @@ class PlaylistRows(Base):
current_note = self.note
if current_note:
self.note = current_note + '\n' + extra_note
self.note = current_note + "\n" + extra_note
else:
self.note = extra_note
@staticmethod
def copy_playlist(session: scoped_session,
src_id: int,
dst_id: int) -> None:
def copy_playlist(session: scoped_session, src_id: int, dst_id: int) -> None:
"""Copy playlist entries"""
src_rows = session.execute(
select(PlaylistRows)
.filter(PlaylistRows.playlist_id == src_id)
).scalars().all()
src_rows = (
session.execute(
select(PlaylistRows).filter(PlaylistRows.playlist_id == src_id)
)
.scalars()
.all()
)
for plr in src_rows:
PlaylistRows(session, dst_id, plr.track_id, plr.plr_rownum,
plr.note)
PlaylistRows(session, dst_id, plr.track_id, plr.plr_rownum, plr.note)
@staticmethod
def delete_higher_rows(
session: scoped_session, playlist_id: int, maxrow: int) -> None:
session: scoped_session, playlist_id: int, maxrow: int
) -> None:
"""
Delete rows in given playlist that have a higher row number
than 'maxrow'
"""
session.execute(
delete(PlaylistRows)
.where(
delete(PlaylistRows).where(
PlaylistRows.playlist_id == playlist_id,
PlaylistRows.plr_rownum > maxrow
PlaylistRows.plr_rownum > maxrow,
)
)
session.flush()
@ -451,11 +436,15 @@ class PlaylistRows(Base):
Ensure the row numbers for passed playlist have no gaps
"""
plrs = session.execute(
select(PlaylistRows)
.where(PlaylistRows.playlist_id == playlist_id)
.order_by(PlaylistRows.plr_rownum)
).scalars().all()
plrs = (
session.execute(
select(PlaylistRows)
.where(PlaylistRows.playlist_id == playlist_id)
.order_by(PlaylistRows.plr_rownum)
)
.scalars()
.all()
)
for i, plr in enumerate(plrs):
plr.plr_rownum = i
@ -464,113 +453,126 @@ class PlaylistRows(Base):
session.commit()
@classmethod
def get_from_id_list(cls, session: scoped_session, playlist_id: int,
plr_ids: List[int]) -> List["PlaylistRows"]:
def get_from_id_list(
cls, session: scoped_session, playlist_id: int, plr_ids: List[int]
) -> List["PlaylistRows"]:
"""
Take a list of PlaylistRows ids and return a list of corresponding
PlaylistRows objects
"""
plrs = session.execute(
select(cls)
.where(
cls.playlist_id == playlist_id,
cls.id.in_(plr_ids)
).order_by(cls.plr_rownum)).scalars().all()
plrs = (
session.execute(
select(cls)
.where(cls.playlist_id == playlist_id, cls.id.in_(plr_ids))
.order_by(cls.plr_rownum)
)
.scalars()
.all()
)
return plrs
@staticmethod
def get_last_used_row(session: scoped_session,
playlist_id: int) -> Optional[int]:
def get_last_used_row(session: scoped_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.plr_rownum))
.where(PlaylistRows.playlist_id == playlist_id)
select(func.max(PlaylistRows.plr_rownum)).where(
PlaylistRows.playlist_id == playlist_id
)
).scalar_one()
@staticmethod
def get_track_plr(session: scoped_session, track_id: int,
playlist_id: int) -> Optional["PlaylistRows"]:
def get_track_plr(
session: scoped_session, track_id: int, playlist_id: int
) -> Optional["PlaylistRows"]:
"""Return first matching PlaylistRows object or None"""
return session.scalars(
select(PlaylistRows)
.where(
PlaylistRows.track_id == track_id,
PlaylistRows.playlist_id == playlist_id
PlaylistRows.playlist_id == playlist_id,
)
.limit(1)
).first()
@classmethod
def get_played_rows(cls, session: scoped_session,
playlist_id: int) -> List["PlaylistRows"]:
def get_played_rows(
cls, session: scoped_session, playlist_id: int
) -> List["PlaylistRows"]:
"""
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)
plrs = (
session.execute(
select(cls)
.where(cls.playlist_id == playlist_id, cls.played.is_(True))
.order_by(cls.plr_rownum)
)
.order_by(cls.plr_rownum)
).scalars().all()
.scalars()
.all()
)
return plrs
@classmethod
def get_rows_with_tracks(
cls, session: scoped_session, playlist_id: int,
cls,
session: scoped_session,
playlist_id: int,
from_row: Optional[int] = None,
to_row: Optional[int] = None) -> List["PlaylistRows"]:
to_row: Optional[int] = None,
) -> List["PlaylistRows"]:
"""
For passed playlist, return a list of rows that
contain tracks
"""
query = select(cls).where(
cls.playlist_id == playlist_id,
cls.track_id.is_not(None)
cls.playlist_id == playlist_id, cls.track_id.is_not(None)
)
if from_row is not None:
query = query.where(cls.plr_rownum >= from_row)
if to_row is not None:
query = query.where(cls.plr_rownum <= to_row)
plrs = (
session.execute((query).order_by(cls.plr_rownum)).scalars().all()
)
plrs = session.execute((query).order_by(cls.plr_rownum)).scalars().all()
return plrs
@classmethod
def get_unplayed_rows(cls, session: scoped_session,
playlist_id: int) -> List["PlaylistRows"]:
def get_unplayed_rows(
cls, session: scoped_session, playlist_id: int
) -> List["PlaylistRows"]:
"""
For passed playlist, return a list of playlist 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)
plrs = (
session.execute(
select(cls)
.where(
cls.playlist_id == playlist_id,
cls.track_id.is_not(None),
cls.played.is_(False),
)
.order_by(cls.plr_rownum)
)
.order_by(cls.plr_rownum)
).scalars().all()
.scalars()
.all()
)
return plrs
@staticmethod
def move_rows_down(session: scoped_session, playlist_id: int,
starting_row: int, move_by: int) -> None:
def move_rows_down(
session: scoped_session, playlist_id: int, starting_row: int, move_by: int
) -> None:
"""
Create space to insert move_by additional rows by incremented row
number from starting_row to end of playlist
@ -580,7 +582,7 @@ class PlaylistRows(Base):
update(PlaylistRows)
.where(
(PlaylistRows.playlist_id == playlist_id),
(PlaylistRows.plr_rownum >= starting_row)
(PlaylistRows.plr_rownum >= starting_row),
)
.values(plr_rownum=PlaylistRows.plr_rownum + move_by)
)
@ -589,7 +591,7 @@ class PlaylistRows(Base):
class Settings(Base):
"""Manage settings"""
__tablename__ = 'settings'
__tablename__ = "settings"
id: int = Column(Integer, primary_key=True, autoincrement=True)
name: str = Column(String(64), nullable=False, unique=True)
@ -602,21 +604,16 @@ class Settings(Base):
return f"<Settings(id={self.id}, name={self.name}, {value=}>"
def __init__(self, session: scoped_session, name: str):
self.name = name
session.add(self)
session.flush()
@classmethod
def get_int_settings(cls, session: scoped_session,
name: str) -> "Settings":
def get_int_settings(cls, session: scoped_session, name: str) -> "Settings":
"""Get setting for an integer or return new setting record"""
try:
return session.execute(
select(cls)
.where(cls.name == name)
).scalar_one()
return session.execute(select(cls).where(cls.name == name)).scalar_one()
except NoResultFound:
return Settings(session, name)
@ -629,7 +626,7 @@ class Settings(Base):
class Tracks(Base):
__tablename__ = 'tracks'
__tablename__ = "tracks"
id: int = Column(Integer, primary_key=True, autoincrement=True)
title = Column(String(256), index=True)
@ -641,8 +638,7 @@ class Tracks(Base):
path: str = Column(String(2048), index=False, nullable=False, unique=True)
mtime = Column(Float, index=True)
bitrate = Column(Integer, nullable=True, default=None)
playlistrows: PlaylistRows = relationship("PlaylistRows",
back_populates="track")
playlistrows: PlaylistRows = relationship("PlaylistRows", back_populates="track")
playlists = association_proxy("playlistrows", "playlist")
playdates: Playdates = relationship("Playdates", back_populates="track")
@ -653,18 +649,18 @@ class Tracks(Base):
)
def __init__(
self,
session: scoped_session,
path: str,
title: Optional[str] = None,
artist: Optional[str] = None,
duration: int = 0,
start_gap: int = 0,
fade_at: Optional[int] = None,
silence_at: Optional[int] = None,
mtime: Optional[float] = None,
lastplayed: Optional[datetime] = None,
) -> None:
self,
session: scoped_session,
path: str,
title: Optional[str] = None,
artist: Optional[str] = None,
duration: int = 0,
start_gap: int = 0,
fade_at: Optional[int] = None,
silence_at: Optional[int] = None,
mtime: Optional[float] = None,
lastplayed: Optional[datetime] = None,
) -> None:
self.path = path
self.title = title
self.artist = artist
@ -693,46 +689,36 @@ class Tracks(Base):
return session.execute(select(cls)).scalars().all()
@classmethod
def get_by_path(cls, session: scoped_session,
path: str) -> Optional["Tracks"]:
def get_by_path(cls, session: scoped_session, path: str) -> Optional["Tracks"]:
"""
Return track with passed path, or None.
"""
try:
return (
session.execute(
select(Tracks)
.where(Tracks.path == path)
).scalar_one()
)
return session.execute(
select(Tracks).where(Tracks.path == path)
).scalar_one()
except NoResultFound:
return None
@classmethod
def search_artists(cls, session: scoped_session,
text: str) -> List["Tracks"]:
def search_artists(cls, session: scoped_session, text: str) -> List["Tracks"]:
"""Search case-insenstively for artists containing str"""
return (
session.execute(
select(cls)
.where(cls.artist.ilike(f"%{text}%"))
.order_by(cls.title)
select(cls).where(cls.artist.ilike(f"%{text}%")).order_by(cls.title)
)
.scalars()
.all()
)
@classmethod
def search_titles(cls, session: scoped_session,
text: str) -> List["Tracks"]:
def search_titles(cls, session: scoped_session, text: str) -> List["Tracks"]:
"""Search case-insenstively for titles containing str"""
return (
session.execute(
select(cls)
.where(cls.title.like(f"{text}%"))
.order_by(cls.title)
select(cls).where(cls.title.like(f"{text}%")).order_by(cls.title)
)
.scalars()
.all()

View File

@ -1,16 +1,16 @@
# import os
import threading
import vlc # type: ignore
import vlc # type: ignore
#
from config import Config
from datetime import datetime
from helpers import file_is_unreadable
from typing import Optional
from time import sleep
from log import log
from PyQt6.QtCore import ( # type: ignore
from PyQt6.QtCore import ( # type: ignore
QRunnable,
QThreadPool,
)
@ -19,7 +19,6 @@ lock = threading.Lock()
class FadeTrack(QRunnable):
def __init__(self, player: vlc.MediaPlayer) -> None:
super().__init__()
self.player = player
@ -47,8 +46,7 @@ class FadeTrack(QRunnable):
for i in range(1, steps + 1):
measures_to_reduce_by += i
volume_factor = 1 - (
measures_to_reduce_by / total_measures_count)
volume_factor = 1 - (measures_to_reduce_by / total_measures_count)
self.player.audio_set_volume(int(original_volume * volume_factor))
sleep(sleep_time)
@ -98,8 +96,7 @@ class Music:
return None
return self.player.get_position()
def play(self, path: str,
position: Optional[float] = None) -> Optional[int]:
def play(self, path: str, position: Optional[float] = None) -> Optional[int]:
"""
Start playing the track at path.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -5,16 +5,12 @@
# parent (eg, bettet bitrate).
import os
import pydymenu # type: ignore
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,
)
@ -29,7 +25,7 @@ process_tag_matches = True
do_processing = True
process_no_matches = True
source_dir = '/home/kae/music/Singles/tmp'
source_dir = "/home/kae/music/Singles/tmp"
parent_dir = os.path.dirname(source_dir)
# #########################################################
@ -46,7 +42,7 @@ def main():
# 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'):
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)
@ -68,8 +64,8 @@ def main():
artists_to_path = {}
for k, v in parents.items():
try:
titles_to_path[v['title'].lower()] = k
artists_to_path[v['artist'].lower()] = k
titles_to_path[v["title"].lower()] = k
artists_to_path[v["artist"].lower()] = k
except AttributeError:
continue
@ -78,44 +74,43 @@ def main():
if not os.path.isfile(new_path):
continue
new_tags = get_tags(new_path)
new_title = new_tags['title']
new_artist = new_tags['artist']
bitrate = new_tags['bitrate']
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())
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)
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():
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}"
)
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)
process_track(
new_path, possible_path, new_title, new_artist, bitrate
)
continue
else:
no_match += 1
@ -132,8 +127,8 @@ def main():
if choice:
old_file = os.path.join(parent_dir, choice[0])
oldtags = get_tags(old_file)
old_title = oldtags['title']
old_artist = oldtags['artist']
old_title = oldtags["title"]
old_artist = oldtags["artist"]
print()
print(f" File name will change {choice[0]}")
print(f"{new_fname}")
@ -232,11 +227,8 @@ def main():
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"
)
print(f"process_track:\n {src=}\n {dst=}\n {title=}, {artist=}\n")
if not do_processing:
return

View File

@ -4,13 +4,7 @@ import os
from config import Config
from helpers import (
fade_point,
get_audio_segment,
get_tags,
leading_silence,
normalise_track,
set_track_metadata,
trailing_silence,
)
from log import log
from models import Tracks
@ -72,12 +66,14 @@ def check_db(session):
print("Invalid paths in database")
print("-------------------------")
for t in paths_not_found:
print(f"""
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")

View File

@ -1,15 +1,15 @@
# https://itnext.io/setting-up-transactional-tests-with-pytest-and-sqlalchemy-b2d726347629
import pytest
import sys
sys.path.append("app")
import models
# Flake8 doesn't like the sys.append within imports
# import sys
# sys.path.append("app")
from sqlalchemy import create_engine
from sqlalchemy.orm import scoped_session, sessionmaker
@pytest.fixture(scope="session")
def connection():
engine = create_engine(
@ -21,7 +21,8 @@ def connection():
@pytest.fixture(scope="session")
def setup_database(connection):
from app.models import Base # noqa E402
from app.models import Base # noqa E402
Base.metadata.bind = connection
Base.metadata.create_all()
# seed_database()

1
devnotes.txt Normal file
View File

@ -0,0 +1 @@
Run Flake8 and Black

Binary file not shown.

Binary file not shown.

View File

@ -29,12 +29,10 @@ Features
* Database backed
* Can be almost entirely keyboard driven
* Playlist management
* Easily add new tracks to playlists
* Show multiple playlists on tabs
* Open multiple playlists in tabs
* Play tracks from any playlist
* Add notes/comments to tracks on playlist
* Automataic olour-coding of notes/comments according to content
* Automatatic colour-coding of notes/comments according to content
* Preview tracks before playing to audience
* Time of day clock
* Elapsed track time counter
@ -42,8 +40,8 @@ Features
* Time to run until track is silent
* Graphic of volume from 5 seconds (configurable) before fade until
track is silent
* Ability to hide played tracks in playlist
* Buttone to drop playout volume by 3dB for talkover
* Optionally hide played tracks in playlist
* Button to drop playout volume by 3dB for talkover
* Playlist displays:
* Title
* Artist
@ -51,18 +49,18 @@ Features
* Estimated start time of track
* Estimated end time of track
* When track was last played
* Bits per second (bps bitrate) of track
* Length of silence in recording before music starts
* Bits per second (bitrate) of track
* Length of leading silence in recording before track starts
* Total track length of arbitrary sections of tracks
* Commands that are sent to OBS Studio (eg, for automated scene
changes)
* Playlist templates
* Move selected/unplayed tracks between playlists
* Down CSV of played tracks between arbitrary dates/times
* Move selected or unplayed tracks between playlists
* Download CSV of tracks played between arbitrary dates/times
* Search for tracks by title or artist
* Automatic search of current/next track in Wikipedia
* Optional search of selected track in Wikipedia
* Optional search of selected track in Songfacts
* Automatic search of current and next track in Wikipedia
* Optional search of any track in Wikipedia
* Optional search of any track in Songfacts
Requirements

View File

@ -221,12 +221,10 @@ production of live internet radio shows.</p>
<ul class="simple">
<li><p>Database backed</p></li>
<li><p>Can be almost entirely keyboard driven</p></li>
<li><p>Playlist management</p></li>
<li><p>Easily add new tracks to playlists</p></li>
<li><p>Show multiple playlists on tabs</p></li>
<li><p>Open multiple playlists in tabs</p></li>
<li><p>Play tracks from any playlist</p></li>
<li><p>Add notes/comments to tracks on playlist</p></li>
<li><p>Automataic olour-coding of notes/comments according to content</p></li>
<li><p>Automatatic colour-coding of notes/comments according to content</p></li>
<li><p>Preview tracks before playing to audience</p></li>
<li><p>Time of day clock</p></li>
<li><p>Elapsed track time counter</p></li>
@ -234,8 +232,8 @@ production of live internet radio shows.</p>
<li><p>Time to run until track is silent</p></li>
<li><p>Graphic of volume from 5 seconds (configurable) before fade until
track is silent</p></li>
<li><p>Ability to hide played tracks in playlist</p></li>
<li><p>Buttone to drop playout volume by 3dB for talkover</p></li>
<li><p>Optionally hide played tracks in playlist</p></li>
<li><p>Button to drop playout volume by 3dB for talkover</p></li>
<li><dl class="simple">
<dt>Playlist displays:</dt><dd><ul>
<li><p>Title</p></li>
@ -244,8 +242,8 @@ track is silent</p></li>
<li><p>Estimated start time of track</p></li>
<li><p>Estimated end time of track</p></li>
<li><p>When track was last played</p></li>
<li><p>Bits per second (bps bitrate) of track</p></li>
<li><p>Length of silence in recording before music starts</p></li>
<li><p>Bits per second (bitrate) of track</p></li>
<li><p>Length of leading silence in recording before track starts</p></li>
<li><p>Total track length of arbitrary sections of tracks</p></li>
<li><p>Commands that are sent to OBS Studio (eg, for automated scene
changes)</p></li>
@ -254,12 +252,12 @@ changes)</p></li>
</dl>
</li>
<li><p>Playlist templates</p></li>
<li><p>Move selected/unplayed tracks between playlists</p></li>
<li><p>Down CSV of played tracks between arbitrary dates/times</p></li>
<li><p>Move selected or unplayed tracks between playlists</p></li>
<li><p>Download CSV of tracks played between arbitrary dates/times</p></li>
<li><p>Search for tracks by title or artist</p></li>
<li><p>Automatic search of current/next track in Wikipedia</p></li>
<li><p>Optional search of selected track in Wikipedia</p></li>
<li><p>Optional search of selected track in Songfacts</p></li>
<li><p>Automatic search of current and next track in Wikipedia</p></li>
<li><p>Optional search of any track in Wikipedia</p></li>
<li><p>Optional search of any track in Songfacts</p></li>
</ul>
</section>
<section id="requirements">

View File

@ -1 +1 @@
Search.setIndex({"docnames": ["development", "index", "installation", "introduction", "reference", "tutorial"], "filenames": ["development.rst", "index.rst", "installation.rst", "introduction.rst", "reference.rst", "tutorial.rst"], "titles": ["Development", "Welcome to MusicMuster\u2019s documentation!", "Installation", "Introduction", "Reference", "Tutorial"], "terms": {"index": 1, "modul": 1, "search": [1, 3], "page": [1, 3], "i": 1, "music": [1, 3], "player": [1, 3], "target": 1, "product": [1, 3], "live": [1, 3], "internet": [1, 3], "radio": [1, 3], "show": [1, 3], "content": 3, "introduct": 1, "instal": [1, 3], "tutori": 1, "refer": 1, "develop": 1, "why": 1, "what": 1, "featur": 1, "requir": 1, "feedback": 1, "bug": 1, "etc": 1, "In": 3, "januari": 3, "2022": 3, "start": 3, "my": 3, "mixcloud": 3, "http": 3, "www": 3, "com": 3, "keithsmusicbox": 3, "until": 3, "had": 3, "been": 3, "an": 3, "station": 3, "which": 3, "me": 3, "us": 3, "window": 3, "playout": 3, "system": 3, "As": 3, "onli": 3, "linux": 3, "set": 3, "up": 3, "pc": 3, "specif": 3, "purpos": 3, "The": 3, "felt": 3, "were": 3, "shortcom": 3, "variou": 3, "area": 3, "onc": 3, "move": 3, "equival": 3, "didn": 3, "t": 3, "have": 3, "same": 3, "wa": 3, "unabl": 3, "find": 3, "one": 3, "met": 3, "criteria": 3, "decid": 3, "see": 3, "how": 3, "practic": 3, "would": 3, "write": 3, "own": 3, "born": 3, "It": 3, "base": 3, "whilst": 3, "could": 3, "gener": 3, "home": 3, "ar": 3, "much": 3, "better": 3, "applic": 3, "role": 3, "ha": 3, "design": 3, "support": 3, "databas": 3, "back": 3, "can": 3, "almost": 3, "entir": 3, "keyboard": 3, "driven": 3, "playlist": 3, "manag": 3, "easili": 3, "add": 3, "new": 3, "track": 3, "multipl": 3, "tab": 3, "plai": 3, "from": 3, "ani": 3, "note": 3, "comment": 3, "automata": 3, "olour": 3, "code": 3, "accord": 3, "preview": 3, "befor": 3, "audienc": 3, "time": 3, "dai": 3, "clock": 3, "elaps": 3, "counter": 3, "run": 3, "fade": 3, "silent": 3, "graphic": 3, "volum": 3, "5": 3, "second": 3, "configur": 3, "abil": 3, "hide": 3, "button": 3, "drop": 3, "3db": 3, "talkov": 3, "titl": 3, "artist": 3, "length": 3, "mm": 3, "ss": 3, "estim": 3, "end": 3, "when": 3, "last": 3, "bit": 3, "per": 3, "bp": 3, "bitrat": 3, "silenc": 3, "record": 3, "total": 3, "arbitrari": 3, "section": 3, "command": 3, "sent": 3, "ob": 3, "studio": 3, "eg": 3, "autom": 3, "scene": 3, "chang": 3, "templat": 3, "select": 3, "unplai": 3, "between": 3, "down": 3, "csv": 3, "date": 3, "automat": 3, "current": 3, "next": 3, "wikipedia": 3, "option": 3, "songfact": 3, "displai": 3, "test": 3, "debian": 3, "12": 3, "bookworm": 3, "howev": 3, "should": 3, "most": 3, "contemporari": 3, "explain": 3, "build": 3, "its": 3, "environ": 3, "automatc": 3, "all": 3, "except": 3, "version": 3, "mariadb": 3, "10": 3, "11": 3, "recent": 3, "suffic": 3, "python": 3, "3": 3, "8": 3, "later": 3, "pleas": 3, "send": 3, "keith": 3, "midnighthax": 3, "edmund": 3, "juli": 3, "2023": 3}, "objects": {}, "objtypes": {}, "objnames": {}, "titleterms": {"welcom": 1, "musicmust": [1, 3], "": 1, "document": 1, "indic": 1, "tabl": 1, "content": 1, "develop": 0, "instal": 2, "introduct": 3, "refer": 4, "tutori": 5, "why": 3, "what": 3, "featur": 3, "requir": 3, "feedback": 3, "bug": 3, "etc": 3, "i": 3}, "envversion": {"sphinx.domains.c": 2, "sphinx.domains.changeset": 1, "sphinx.domains.citation": 1, "sphinx.domains.cpp": 8, "sphinx.domains.index": 1, "sphinx.domains.javascript": 2, "sphinx.domains.math": 2, "sphinx.domains.python": 3, "sphinx.domains.rst": 2, "sphinx.domains.std": 2, "sphinx": 57}, "alltitles": {"Welcome to MusicMuster\u2019s documentation!": [[1, "welcome-to-musicmuster-s-documentation"]], "Contents": [[1, "contents"]], "Indices and tables": [[1, "indices-and-tables"]], "Development": [[0, "development"]], "Installation": [[2, "installation"]], "Reference": [[4, "reference"]], "Tutorial": [[5, "tutorial"]], "Introduction": [[3, "introduction"]], "Why MusicMuster?": [[3, "why-musicmuster"]], "What is MusicMuster?": [[3, "what-is-musicmuster"]], "Features": [[3, "features"]], "Requirements": [[3, "requirements"]], "Feedback, bugs, etc": [[3, "feedback-bugs-etc"]]}, "indexentries": {}})
Search.setIndex({"docnames": ["development", "index", "installation", "introduction", "reference", "tutorial"], "filenames": ["development.rst", "index.rst", "installation.rst", "introduction.rst", "reference.rst", "tutorial.rst"], "titles": ["Development", "Welcome to MusicMuster\u2019s documentation!", "Installation", "Introduction", "Reference", "Tutorial"], "terms": {"index": 1, "modul": 1, "search": [1, 3], "page": [1, 3], "i": 1, "music": [1, 3], "player": [1, 3], "target": 1, "product": [1, 3], "live": [1, 3], "internet": [1, 3], "radio": [1, 3], "show": [1, 3], "content": 3, "introduct": 1, "instal": [1, 3], "tutori": 1, "refer": 1, "develop": 1, "why": 1, "what": 1, "featur": 1, "requir": 1, "feedback": 1, "bug": 1, "etc": 1, "In": 3, "januari": 3, "2022": 3, "start": 3, "my": 3, "mixcloud": 3, "http": 3, "www": 3, "com": 3, "keithsmusicbox": 3, "until": 3, "had": 3, "been": 3, "an": 3, "station": 3, "which": 3, "me": 3, "us": 3, "window": 3, "playout": 3, "system": 3, "As": 3, "onli": 3, "linux": 3, "set": 3, "up": 3, "pc": 3, "specif": 3, "purpos": 3, "The": 3, "felt": 3, "were": 3, "shortcom": 3, "variou": 3, "area": 3, "onc": 3, "move": 3, "equival": 3, "didn": 3, "t": 3, "have": 3, "same": 3, "wa": 3, "unabl": 3, "find": 3, "one": 3, "met": 3, "criteria": 3, "decid": 3, "see": 3, "how": 3, "practic": 3, "would": 3, "write": 3, "own": 3, "born": 3, "It": 3, "base": 3, "whilst": 3, "could": 3, "gener": 3, "home": 3, "ar": 3, "much": 3, "better": 3, "applic": 3, "role": 3, "ha": 3, "design": 3, "support": 3, "databas": 3, "back": 3, "can": 3, "almost": 3, "entir": 3, "keyboard": 3, "driven": 3, "playlist": 3, "manag": [], "easili": [], "add": 3, "new": [], "track": 3, "multipl": 3, "tab": 3, "plai": 3, "from": 3, "ani": 3, "note": 3, "comment": 3, "automata": [], "olour": [], "code": 3, "accord": 3, "preview": 3, "befor": 3, "audienc": 3, "time": 3, "dai": 3, "clock": 3, "elaps": 3, "counter": 3, "run": 3, "fade": 3, "silent": 3, "graphic": 3, "volum": 3, "5": 3, "second": 3, "configur": 3, "abil": [], "hide": 3, "button": 3, "drop": 3, "3db": 3, "talkov": 3, "titl": 3, "artist": 3, "length": 3, "mm": 3, "ss": 3, "estim": 3, "end": 3, "when": 3, "last": 3, "bit": 3, "per": 3, "bp": [], "bitrat": 3, "silenc": 3, "record": 3, "total": 3, "arbitrari": 3, "section": 3, "command": 3, "sent": 3, "ob": 3, "studio": 3, "eg": 3, "autom": 3, "scene": 3, "chang": 3, "templat": 3, "select": 3, "unplai": 3, "between": 3, "down": [], "csv": 3, "date": 3, "automat": 3, "current": 3, "next": 3, "wikipedia": 3, "option": 3, "songfact": 3, "displai": 3, "test": 3, "debian": 3, "12": 3, "bookworm": 3, "howev": 3, "should": 3, "most": 3, "contemporari": 3, "explain": 3, "build": 3, "its": 3, "environ": 3, "automatc": 3, "all": 3, "except": 3, "version": 3, "mariadb": 3, "10": 3, "11": 3, "recent": 3, "suffic": 3, "python": 3, "3": 3, "8": 3, "later": 3, "pleas": 3, "send": 3, "keith": 3, "midnighthax": 3, "edmund": 3, "juli": 3, "2023": 3, "open": 3, "automatat": 3, "colour": 3, "lead": 3, "download": 3}, "objects": {}, "objtypes": {}, "objnames": {}, "titleterms": {"welcom": 1, "musicmust": [1, 3], "": 1, "document": 1, "indic": 1, "tabl": 1, "content": 1, "develop": 0, "instal": 2, "introduct": 3, "refer": 4, "tutori": 5, "why": 3, "what": 3, "featur": 3, "requir": 3, "feedback": 3, "bug": 3, "etc": 3, "i": 3}, "envversion": {"sphinx.domains.c": 2, "sphinx.domains.changeset": 1, "sphinx.domains.citation": 1, "sphinx.domains.cpp": 8, "sphinx.domains.index": 1, "sphinx.domains.javascript": 2, "sphinx.domains.math": 2, "sphinx.domains.python": 3, "sphinx.domains.rst": 2, "sphinx.domains.std": 2, "sphinx": 57}, "alltitles": {"Welcome to MusicMuster\u2019s documentation!": [[1, "welcome-to-musicmuster-s-documentation"]], "Contents": [[1, "contents"]], "Indices and tables": [[1, "indices-and-tables"]], "Development": [[0, "development"]], "Installation": [[2, "installation"]], "Reference": [[4, "reference"]], "Tutorial": [[5, "tutorial"]], "Introduction": [[3, "introduction"]], "Why MusicMuster?": [[3, "why-musicmuster"]], "What is MusicMuster?": [[3, "what-is-musicmuster"]], "Features": [[3, "features"]], "Requirements": [[3, "requirements"]], "Feedback, bugs, etc": [[3, "feedback-bugs-etc"]]}, "indexentries": {}})

View File

@ -29,11 +29,10 @@ Features
* Database backed
* Can be almost entirely keyboard driven
* Easily add new tracks to playlists
* Show multiple playlists on tabs
* Open multiple playlists in tabs
* Play tracks from any playlist
* Add notes/comments to tracks on playlist
* Automataic olour-coding of notes/comments according to content
* Automatatic colour-coding of notes/comments according to content
* Preview tracks before playing to audience
* Time of day clock
* Elapsed track time counter
@ -41,8 +40,8 @@ Features
* Time to run until track is silent
* Graphic of volume from 5 seconds (configurable) before fade until
track is silent
* Ability to hide played tracks in playlist
* Buttone to drop playout volume by 3dB for talkover
* Optionally hide played tracks in playlist
* Button to drop playout volume by 3dB for talkover
* Playlist displays:
* Title
* Artist
@ -50,18 +49,18 @@ Features
* Estimated start time of track
* Estimated end time of track
* When track was last played
* Bits per second (bps bitrate) of track
* Length of silence in recording before music starts
* Bits per second (bitrate) of track
* Length of leading silence in recording before track starts
* Total track length of arbitrary sections of tracks
* Commands that are sent to OBS Studio (eg, for automated scene
changes)
* Playlist templates
* Move selected/unplayed tracks between playlists
* Down CSV of played tracks between arbitrary dates/times
* Move selected or unplayed tracks between playlists
* Download CSV of tracks played between arbitrary dates/times
* Search for tracks by title or artist
* Automatic search of current/next track in Wikipedia
* Optional search of selected track in Wikipedia
* Optional search of selected track in Songfacts
* Automatic search of current and next track in Wikipedia
* Optional search of any track in Wikipedia
* Optional search of any track in Songfacts
Requirements

802
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -42,6 +42,8 @@ mypy = "^0.991"
pudb = "^2022.1.3"
sphinx = "^7.0.1"
furo = "^2023.5.20"
black = "^23.3.0"
flakehell = "^0.9.0"
[build-system]
requires = ["poetry-core>=1.0.0"]

View File

@ -2,6 +2,7 @@
from PyQt5 import QtGui, QtWidgets
class TabBar(QtWidgets.QTabBar):
def paintEvent(self, event):
painter = QtWidgets.QStylePainter(self)
@ -13,16 +14,18 @@ class TabBar(QtWidgets.QTabBar):
painter.drawControl(QtWidgets.QStyle.CE_TabBarTabShape, option)
painter.drawControl(QtWidgets.QStyle.CE_TabBarTabLabel, option)
class Window(QtWidgets.QTabWidget):
def __init__(self):
QtWidgets.QTabWidget.__init__(self)
self.setTabBar(TabBar(self))
for color in 'tomato orange yellow lightgreen skyblue plum'.split():
for color in "tomato orange yellow lightgreen skyblue plum".split():
self.addTab(QtWidgets.QWidget(self), color)
if __name__ == '__main__':
if __name__ == "__main__":
import sys
app = QtWidgets.QApplication(sys.argv)
window = Window()
window.resize(420, 200)

View File

@ -1,7 +1,12 @@
from config import Config
from datetime import datetime, timedelta
from helpers import *
from models import Tracks
from helpers import (
fade_point,
get_audio_segment,
get_tags,
get_relative_date,
leading_silence,
ms_to_mmss,
)
def test_fade_point():
@ -18,8 +23,8 @@ def test_fade_point():
testdata = eval(f.read())
# Volume detection can vary, so ± 1 second is OK
assert fade_at < testdata['fade_at'] + 1000
assert fade_at > testdata['fade_at'] - 1000
assert fade_at < testdata["fade_at"] + 1000
assert fade_at > testdata["fade_at"] - 1000
def test_get_tags():
@ -32,8 +37,8 @@ def test_get_tags():
with open(test_track_data) as f:
testdata = eval(f.read())
assert tags['artist'] == testdata['artist']
assert tags['title'] == testdata['title']
assert tags["artist"] == testdata["artist"]
assert tags["title"] == testdata["title"]
def test_get_relative_date():
@ -44,8 +49,7 @@ def test_get_relative_date():
eight_days_ago = today_at_10 - timedelta(days=8)
assert get_relative_date(eight_days_ago, today_at_11) == "1 week, 1 day ago"
sixteen_days_ago = today_at_10 - timedelta(days=16)
assert get_relative_date(
sixteen_days_ago, today_at_11) == "2 weeks, 2 days ago"
assert get_relative_date(sixteen_days_ago, today_at_11) == "2 weeks, 2 days ago"
def test_leading_silence():
@ -62,8 +66,8 @@ def test_leading_silence():
testdata = eval(f.read())
# Volume detection can vary, so ± 1 second is OK
assert silence_at < testdata['leading_silence'] + 1000
assert silence_at > testdata['leading_silence'] - 1000
assert silence_at < testdata["leading_silence"] + 1000
assert silence_at > testdata["leading_silence"] - 1000
def test_ms_to_mmss():

View File

@ -135,7 +135,6 @@ def test_playdates_remove_track(session):
track_path = "/a/b/c"
track = Tracks(session, track_path)
playdate = Playdates(session, track.id)
Playdates.remove_track(session, track.id)
last_played = Playdates.last_played(session, track.id)
@ -149,10 +148,8 @@ def test_playlist_create(session):
def test_playlist_add_note(session):
note_text = "my note"
note_row = 2
playlist = Playlists(session, "my playlist")
note = playlist.add_note(session, note_row, note_text)
assert len(playlist.notes) == 1
playlist_note = playlist.notes[0]
@ -356,9 +353,7 @@ def test_tracks_get_all_paths(session):
def test_tracks_get_all_tracks(session):
# Need two tracks
track1_path = "/a/b/c"
track1 = Tracks(session, track1_path)
track2_path = "/m/n/o"
track2 = Tracks(session, track2_path)
result = Tracks.get_all_tracks(session)
assert track1_path in [a.path for a in result]
@ -369,9 +364,7 @@ def test_tracks_by_filename(session):
track1_path = "/a/b/c"
track1 = Tracks(session, track1_path)
assert Tracks.get_by_filename(
session, os.path.basename(track1_path)
) is track1
assert Tracks.get_by_filename(session, os.path.basename(track1_path)) is track1
def test_tracks_by_path(session):
@ -403,26 +396,24 @@ def test_tracks_rescan(session):
# Re-read the track
track_read = Tracks.get_by_path(session, test_track_path)
assert track_read.duration == testdata['duration']
assert track_read.start_gap == testdata['leading_silence']
assert track_read.duration == testdata["duration"]
assert track_read.start_gap == testdata["leading_silence"]
# Silence detection can vary, so ± 1 second is OK
assert track_read.fade_at < testdata['fade_at'] + 1000
assert track_read.fade_at > testdata['fade_at'] - 1000
assert track_read.silence_at < testdata['trailing_silence'] + 1000
assert track_read.silence_at > testdata['trailing_silence'] - 1000
assert track_read.fade_at < testdata["fade_at"] + 1000
assert track_read.fade_at > testdata["fade_at"] - 1000
assert track_read.silence_at < testdata["trailing_silence"] + 1000
assert track_read.silence_at > testdata["trailing_silence"] - 1000
def test_tracks_remove_by_path(session):
track1_path = "/a/b/c"
track1 = Tracks(session, track1_path)
assert len(Tracks.get_all_tracks(session)) == 1
Tracks.remove_by_path(session, track1_path)
assert len(Tracks.get_all_tracks(session)) == 0
def test_tracks_search_artists(session):
track1_path = "/a/b/c"
track1_artist = "Artist One"
track1 = Tracks(session, track1_path)
@ -435,7 +426,6 @@ def test_tracks_search_artists(session):
session.commit()
x = Tracks.get_all_tracks(session)
artist_first_word = track1_artist.split()[0].lower()
assert len(Tracks.search_artists(session, artist_first_word)) == 2
assert len(Tracks.search_artists(session, track1_artist)) == 1
@ -454,7 +444,6 @@ def test_tracks_search_titles(session):
session.commit()
x = Tracks.get_all_tracks(session)
title_first_word = track1_title.split()[0].lower()
assert len(Tracks.search_titles(session, title_first_word)) == 2
assert len(Tracks.search_titles(session, track1_title)) == 1

View File

@ -3,7 +3,6 @@ from PyQt5.QtCore import Qt
from app import playlists
from app import models
from app import musicmuster
from app import dbconfig
def seed2tracks(session):
@ -78,7 +77,6 @@ def test_save_and_restore(qtbot, session):
def test_meta_all_clear(qtbot, session):
# Create playlist
playlist = models.Playlists(session, "my playlist")
playlist_tab = playlists.PlaylistTab(None, session, playlist.id)
@ -107,7 +105,6 @@ def test_meta_all_clear(qtbot, session):
def test_meta(qtbot, session):
# Create playlist
playlist = playlists.Playlists(session, "my playlist")
playlist_tab = playlists.PlaylistTab(None, session, playlist.id)
@ -141,7 +138,7 @@ def test_meta(qtbot, session):
# Add a note
note_text = "my note"
note_row = 7 # will be added as row 3
note_row = 7 # will be added as row 3
note = models.Notes(session, playlist.id, note_row, note_text)
playlist_tab._insert_note(session, note)
@ -211,7 +208,6 @@ def test_clear_next(qtbot, session):
def test_get_selected_row(qtbot, monkeypatch, session):
monkeypatch.setattr(musicmuster, "Session", session)
monkeypatch.setattr(playlists, "Session", session)
@ -236,15 +232,12 @@ def test_get_selected_row(qtbot, monkeypatch, session):
row0_item0 = playlist_tab.item(0, 0)
assert row0_item0 is not None
rect = playlist_tab.visualItemRect(row0_item0)
qtbot.mouseClick(
playlist_tab.viewport(), Qt.LeftButton, pos=rect.center()
)
qtbot.mouseClick(playlist_tab.viewport(), Qt.LeftButton, pos=rect.center())
row_number = playlist_tab.get_selected_row()
assert row_number == 0
def test_set_next(qtbot, monkeypatch, session):
monkeypatch.setattr(musicmuster, "Session", session)
monkeypatch.setattr(playlists, "Session", session)
seed2tracks(session)
@ -274,14 +267,11 @@ def test_set_next(qtbot, monkeypatch, session):
row0_item2 = playlist_tab.item(0, 2)
assert row0_item2 is not None
rect = playlist_tab.visualItemRect(row0_item2)
qtbot.mouseClick(
playlist_tab.viewport(), Qt.LeftButton, pos=rect.center()
)
qtbot.mouseClick(playlist_tab.viewport(), Qt.LeftButton, pos=rect.center())
selected_title = playlist_tab.get_selected_title()
assert selected_title == track1_title
qtbot.keyPress(playlist_tab.viewport(), "N",
modifier=Qt.ControlModifier)
qtbot.keyPress(playlist_tab.viewport(), "N", modifier=Qt.ControlModifier)
qtbot.wait(1000)

24
tree.py
View File

@ -9,9 +9,10 @@ datas = {
],
"No Category": [
("New Game", "Playnite", "", "", "Never", "Not Plated", ""),
]
],
}
class GroupDelegate(QtWidgets.QStyledItemDelegate):
def __init__(self, parent=None):
super(GroupDelegate, self).__init__(parent)
@ -25,6 +26,7 @@ class GroupDelegate(QtWidgets.QStyledItemDelegate):
option.features |= QtWidgets.QStyleOptionViewItem.HasDecoration
option.icon = self._minus_icon if is_open else self._plus_icon
class GroupView(QtWidgets.QTreeView):
def __init__(self, model, parent=None):
super(GroupView, self).__init__(parent)
@ -48,7 +50,18 @@ class GroupModel(QtGui.QStandardItemModel):
def __init__(self, parent=None):
super(GroupModel, self).__init__(parent)
self.setColumnCount(8)
self.setHorizontalHeaderLabels(["", "Name", "Library", "Release Date", "Genre(s)", "Last Played", "Time Played", ""])
self.setHorizontalHeaderLabels(
[
"",
"Name",
"Library",
"Release Date",
"Genre(s)",
"Last Played",
"Time Played",
"",
]
)
for i in range(self.columnCount()):
it = self.horizontalHeaderItem(i)
it.setForeground(QtGui.QColor("#F2F2F2"))
@ -84,7 +97,7 @@ class GroupModel(QtGui.QStandardItemModel):
item.setEditable(False)
item.setBackground(QtGui.QColor("#0D1225"))
item.setForeground(QtGui.QColor("#F2F2F2"))
group_item.setChild(j, i+1, item)
group_item.setChild(j, i + 1, item)
class MainWindow(QtWidgets.QMainWindow):
@ -100,11 +113,12 @@ class MainWindow(QtWidgets.QMainWindow):
for children in childrens:
model.append_element_to_group(group_item, children)
if __name__ == '__main__':
if __name__ == "__main__":
import sys
app = QtWidgets.QApplication(sys.argv)
w = MainWindow()
w.resize(720, 240)
w.show()
sys.exit(app.exec_())

1
web.py
View File

@ -1,6 +1,5 @@
#!/usr/bin/env python3
import sys
from pprint import pprint
from PyQt6.QtWidgets import QApplication, QLabel
from PyQt6.QtGui import QColor, QPalette