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
@ -16,7 +15,7 @@ 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 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.
@ -56,7 +58,8 @@ def fade_point(
while (
audio_segment[trim_ms : trim_ms + chunk_size].dBFS < fade_threshold
and trim_ms > 0): # noqa W503
and trim_ms > 0
): # noqa W503
trim_ms -= chunk_size
# if there is no trailing silence, return lenght of track (it's less
@ -77,9 +80,9 @@ 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'):
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.
@ -147,7 +151,8 @@ 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:
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

@ -2,7 +2,7 @@ import urllib.parse
from datetime import datetime
from slugify import slugify # type: ignore
from typing import Dict, Optional
from typing import Dict
from PyQt6.QtCore import QUrl # type: ignore
from PyQt6.QtWebEngineWidgets import QWebEngineView
from PyQt6.QtWidgets import QTabWidget
@ -47,9 +47,7 @@ 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
@ -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,
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:
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,12 +372,13 @@ class PlaylistRows(Base):
f"note={self.note}, plr_rownum={self.plr_rownum}>"
)
def __init__(self,
def __init__(
self,
session: scoped_session,
playlist_id: int,
track_id: Optional[int],
row_number: int,
note: str = ""
note: str = "",
) -> None:
"""Create PlaylistRows object"""
@ -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(
plrs = (
session.execute(
select(PlaylistRows)
.where(PlaylistRows.playlist_id == playlist_id)
.order_by(PlaylistRows.plr_rownum)
).scalars().all()
)
.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(
plrs = (
session.execute(
select(cls)
.where(
cls.playlist_id == playlist_id,
cls.id.in_(plr_ids)
).order_by(cls.plr_rownum)).scalars().all()
.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(
plrs = (
session.execute(
select(cls)
.where(
cls.playlist_id == playlist_id,
cls.played.is_(True)
)
.where(cls.playlist_id == playlist_id, cls.played.is_(True))
.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(
plrs = (
session.execute(
select(cls)
.where(
cls.playlist_id == playlist_id,
cls.track_id.is_not(None),
cls.played.is_(False)
cls.played.is_(False),
)
.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")
@ -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)
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,9 +1,9 @@
# import os
import threading
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
@ -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.

View File

@ -11,7 +11,6 @@ import subprocess
import sys
import threading
import icons_rc
from datetime import datetime, timedelta
from pygame import mixer
from time import sleep
@ -61,15 +60,7 @@ from dbconfig import (
)
import helpers
import music
from models import (
Base,
Carts,
Playdates,
PlaylistRows,
Playlists,
Settings,
Tracks
)
from models import Base, Carts, Playdates, PlaylistRows, Playlists, Settings, Tracks
from config import Config
from playlists import PlaylistTab
from ui.dlg_cart_ui import Ui_DialogCartEdit # type: ignore
@ -93,7 +84,7 @@ class CartButton(QPushButton):
self.cart_id = cart.id
if cart.path and cart.enabled and not cart.duration:
tags = helpers.get_tags(cart.path)
cart.duration = tags['duration']
cart.duration = tags["duration"]
self.duration = cart.duration
self.path = cart.path
self.player = None
@ -110,8 +101,9 @@ class CartButton(QPushButton):
self.pgb.setTextVisible(False)
self.pgb.setVisible(False)
palette = self.pgb.palette()
palette.setColor(QPalette.ColorRole.Highlight,
QColor(Config.COLOUR_CART_PROGRESSBAR))
palette.setColor(
QPalette.ColorRole.Highlight, QColor(Config.COLOUR_CART_PROGRESSBAR)
)
self.pgb.setPalette(palette)
self.pgb.setGeometry(0, 0, self.width(), 10)
self.pgb.setMinimum(0)
@ -157,16 +149,13 @@ class FadeCurve:
# Start point of curve is Config.FADE_CURVE_MS_BEFORE_FADE
# milliseconds before fade starts to silence
self.start_ms = max(
0, track.fade_at - Config.FADE_CURVE_MS_BEFORE_FADE - 1)
self.start_ms = max(0, track.fade_at - Config.FADE_CURVE_MS_BEFORE_FADE - 1)
self.end_ms = track.silence_at
self.audio_segment = audio[self.start_ms : self.end_ms]
self.graph_array = np.array(self.audio_segment.get_array_of_samples())
# Calculate the factor to map milliseconds of track to array
self.ms_to_array_factor = (
len(self.graph_array) / (self.end_ms - self.start_ms)
)
self.ms_to_array_factor = len(self.graph_array) / (self.end_ms - self.start_ms)
self.region = None
@ -192,10 +181,7 @@ class FadeCurve:
if self.region is None:
# Create the region now that we're into fade
self.region = pg.LinearRegionItem(
[0, 0],
bounds=[0, len(self.graph_array)]
)
self.region = pg.LinearRegionItem([0, 0], bounds=[0, len(self.graph_array)])
self.GraphWidget.addItem(self.region)
# Update region position
@ -286,8 +272,9 @@ class PlaylistTrack:
f"playlist_id={self.playlist_id}>"
)
def set_plr(self, session: scoped_session, plr: PlaylistRows,
tab: PlaylistTab) -> None:
def set_plr(
self, session: scoped_session, plr: PlaylistRows, tab: PlaylistTab
) -> None:
"""
Update with new plr information
"""
@ -323,8 +310,7 @@ class PlaylistTrack:
self.start_time = datetime.now()
if self.duration:
self.end_time = (
self.start_time + timedelta(milliseconds=self.duration))
self.end_time = self.start_time + timedelta(milliseconds=self.duration)
class Window(QMainWindow, Ui_MainWindow):
@ -355,14 +341,15 @@ class Window(QMainWindow, Ui_MainWindow):
self.txtSearch.setHidden(True)
self.hide_played_tracks = False
mixer.init()
self.widgetFadeVolume.hideAxis('bottom')
self.widgetFadeVolume.hideAxis('left')
self.widgetFadeVolume.hideAxis("bottom")
self.widgetFadeVolume.hideAxis("left")
self.widgetFadeVolume.setDefaultPadding(0)
self.widgetFadeVolume.setBackground(Config.FADE_CURVE_BACKGROUND)
FadeCurve.GraphWidget = self.widgetFadeVolume
self.visible_playlist_tab: Callable[[], PlaylistTab] = \
self.tabPlaylist.currentWidget
self.visible_playlist_tab: Callable[
[], PlaylistTab
] = self.tabPlaylist.currentWidget
self.load_last_playlists()
if Config.CARTS_HIDE:
@ -380,10 +367,8 @@ class Window(QMainWindow, Ui_MainWindow):
try:
git_tag = str(
subprocess.check_output(
['git', 'describe'], stderr=subprocess.STDOUT
)
).strip('\'b\\n')
subprocess.check_output(["git", "describe"], stderr=subprocess.STDOUT)
).strip("'b\\n")
except subprocess.CalledProcessError as exc_info:
git_tag = str(exc_info.output)
@ -395,7 +380,7 @@ class Window(QMainWindow, Ui_MainWindow):
self,
"About",
f"MusicMuster {git_tag}\n\nDatabase: {dbname}",
QMessageBox.StandardButton.Ok
QMessageBox.StandardButton.Ok,
)
def cart_configure(self, cart: Carts, btn: CartButton) -> None:
@ -433,15 +418,13 @@ class Window(QMainWindow, Ui_MainWindow):
# Don't allow clicks while we're playing
btn.setEnabled(False)
if not btn.player:
log.debug(
f"musicmuster.cart_click(): no player assigned ({btn=})")
log.debug(f"musicmuster.cart_click(): no player assigned ({btn=})")
return
btn.player.play()
btn.is_playing = True
colour = Config.COLOUR_CART_PLAYING
thread = threading.Thread(target=self.cart_progressbar,
args=(btn,))
thread = threading.Thread(target=self.cart_progressbar, args=(btn,))
thread.start()
else:
colour = Config.COLOUR_CART_ERROR
@ -469,7 +452,7 @@ class Window(QMainWindow, Ui_MainWindow):
return
if cart.path and not helpers.file_is_unreadable(cart.path):
tags = helpers.get_tags(cart.path)
cart.duration = tags['duration']
cart.duration = tags["duration"]
cart.enabled = dlg.ui.chkEnabled.isChecked()
cart.name = name
@ -488,8 +471,7 @@ class Window(QMainWindow, Ui_MainWindow):
for cart_number in range(1, Config.CARTS_COUNT + 1):
cart = session.query(Carts).get(cart_number)
if cart is None:
cart = Carts(session, cart_number,
name=f"Cart #{cart_number}")
cart = Carts(session, cart_number, name=f"Cart #{cart_number}")
btn = CartButton(self, cart)
btn.clicked.connect(self.cart_click)
@ -555,27 +537,25 @@ class Window(QMainWindow, Ui_MainWindow):
if self.playing:
event.ignore()
helpers.show_warning(
self,
"Track playing",
"Can't close application while track is playing")
self, "Track playing", "Can't close application while track is playing"
)
else:
with Session() as session:
record = Settings.get_int_settings(
session, "mainwindow_height")
record = Settings.get_int_settings(session, "mainwindow_height")
if record.f_int != self.height():
record.update(session, {'f_int': self.height()})
record.update(session, {"f_int": self.height()})
record = Settings.get_int_settings(session, "mainwindow_width")
if record.f_int != self.width():
record.update(session, {'f_int': self.width()})
record.update(session, {"f_int": self.width()})
record = Settings.get_int_settings(session, "mainwindow_x")
if record.f_int != self.x():
record.update(session, {'f_int': self.x()})
record.update(session, {"f_int": self.x()})
record = Settings.get_int_settings(session, "mainwindow_y")
if record.f_int != self.y():
record.update(session, {'f_int': self.y()})
record.update(session, {"f_int": self.y()})
# Save splitter settings
splitter_sizes = self.splitter.sizes()
@ -584,16 +564,15 @@ class Window(QMainWindow, Ui_MainWindow):
record = Settings.get_int_settings(session, "splitter_top")
if record.f_int != splitter_top:
record.update(session, {'f_int': splitter_top})
record.update(session, {"f_int": splitter_top})
record = Settings.get_int_settings(session, "splitter_bottom")
if record.f_int != splitter_bottom:
record.update(session, {'f_int': splitter_bottom})
record.update(session, {"f_int": splitter_bottom})
# Save current tab
record = Settings.get_int_settings(session, "active_tab")
record.update(session,
{'f_int': self.tabPlaylist.currentIndex()})
record.update(session, {"f_int": self.tabPlaylist.currentIndex()})
event.accept()
@ -613,10 +592,8 @@ class Window(QMainWindow, Ui_MainWindow):
"""
# Don't close current track playlist
if self.tabPlaylist.widget(tab_index) == (
self.current_track.playlist_tab):
self.statusbar.showMessage(
"Can't close current track playlist", 5000)
if self.tabPlaylist.widget(tab_index) == (self.current_track.playlist_tab):
self.statusbar.showMessage("Can't close current track playlist", 5000)
return False
# Attempt to close next track playlist
@ -643,15 +620,17 @@ class Window(QMainWindow, Ui_MainWindow):
self.actionClosePlaylist.triggered.connect(self.close_playlist_tab)
self.actionDeletePlaylist.triggered.connect(self.delete_playlist)
self.actionDownload_CSV_of_played_tracks.triggered.connect(
self.download_played_tracks)
self.actionEnable_controls.triggered.connect(
self.enable_play_next_controls)
self.download_played_tracks
)
self.actionEnable_controls.triggered.connect(self.enable_play_next_controls)
self.actionExport_playlist.triggered.connect(self.export_playlist_tab)
self.actionFade.triggered.connect(self.fade)
self.actionFind_next.triggered.connect(
lambda: self.tabPlaylist.currentWidget().search_next())
lambda: self.tabPlaylist.currentWidget().search_next()
)
self.actionFind_previous.triggered.connect(
lambda: self.tabPlaylist.currentWidget().search_previous())
lambda: self.tabPlaylist.currentWidget().search_previous()
)
self.actionImport.triggered.connect(self.import_track)
self.actionInsertSectionHeader.triggered.connect(self.insert_header)
self.actionInsertTrack.triggered.connect(self.insert_track)
@ -666,13 +645,14 @@ class Window(QMainWindow, Ui_MainWindow):
self.actionResume.triggered.connect(self.resume)
self.actionSave_as_template.triggered.connect(self.save_as_template)
self.actionSearch_title_in_Songfacts.triggered.connect(
lambda: self.tabPlaylist.currentWidget().lookup_row_in_songfacts())
lambda: self.tabPlaylist.currentWidget().lookup_row_in_songfacts()
)
self.actionSearch_title_in_Wikipedia.triggered.connect(
lambda: self.tabPlaylist.currentWidget().lookup_row_in_wikipedia())
lambda: self.tabPlaylist.currentWidget().lookup_row_in_wikipedia()
)
self.actionSearch.triggered.connect(self.search_playlist)
self.actionSelect_next_track.triggered.connect(self.select_next_row)
self.actionSelect_previous_track.triggered.connect(
self.select_previous_row)
self.actionSelect_previous_track.triggered.connect(self.select_previous_row)
self.actionMoveUnplayed.triggered.connect(self.move_unplayed)
self.actionSetNext.triggered.connect(self.set_selected_track_next)
self.actionSkipToNext.triggered.connect(self.play_next)
@ -691,10 +671,9 @@ class Window(QMainWindow, Ui_MainWindow):
self.timer.timeout.connect(self.tick)
def create_playlist(self,
session: scoped_session,
playlist_name: Optional[str] = None) \
-> Optional[Playlists]:
def create_playlist(
self, session: scoped_session, playlist_name: Optional[str] = None
) -> Optional[Playlists]:
"""Create new playlist"""
playlist_name = self.solicit_playlist_name()
@ -712,8 +691,7 @@ class Window(QMainWindow, Ui_MainWindow):
if playlist:
self.create_playlist_tab(session, playlist)
def create_playlist_tab(self, session: scoped_session,
playlist: Playlists) -> int:
def create_playlist_tab(self, session: scoped_session, playlist: Playlists) -> int:
"""
Take the passed playlist database object, create a playlist tab and
add tab to display. Return index number of tab.
@ -722,8 +700,11 @@ class Window(QMainWindow, Ui_MainWindow):
assert playlist.id
playlist_tab = PlaylistTab(
musicmuster=self, session=session, playlist_id=playlist.id,
signals=self.signals)
musicmuster=self,
session=session,
playlist_id=playlist.id,
signals=self.signals,
)
idx = self.tabPlaylist.addTab(playlist_tab, playlist.name)
self.tabPlaylist.setCurrentIndex(idx)
@ -737,8 +718,9 @@ class Window(QMainWindow, Ui_MainWindow):
with Session() as session:
# Save the selected PlaylistRows items ready for a later
# paste
self.selected_plrs = (
self.visible_playlist_tab().get_selected_playlistrows(session))
self.selected_plrs = self.visible_playlist_tab().get_selected_playlistrows(
session
)
def debug(self):
"""Invoke debugger"""
@ -746,6 +728,7 @@ class Window(QMainWindow, Ui_MainWindow):
visible_playlist_id = self.visible_playlist_tab().playlist_id
print(f"Active playlist id={visible_playlist_id}")
import ipdb # type: ignore
ipdb.set_trace()
def delete_playlist(self) -> None:
@ -757,9 +740,9 @@ class Window(QMainWindow, Ui_MainWindow):
playlist_id = self.visible_playlist_tab().playlist_id
playlist = session.get(Playlists, playlist_id)
if playlist:
if helpers.ask_yes_no("Delete playlist",
f"Delete playlist '{playlist.name}': "
"Are you sure?"
if helpers.ask_yes_no(
"Delete playlist",
f"Delete playlist '{playlist.name}': " "Are you sure?",
):
if self.close_playlist_tab():
playlist.delete(session)
@ -780,9 +763,10 @@ class Window(QMainWindow, Ui_MainWindow):
start_dt = dlg.ui.dateTimeEdit.dateTime().toPyDateTime()
# Get output filename
pathspec = QFileDialog.getSaveFileName(
self, 'Save CSV of tracks played',
self,
"Save CSV of tracks played",
directory="/tmp/playlist.csv",
filter="CSV files (*.csv)"
filter="CSV files (*.csv)",
)
if not pathspec:
return
@ -794,9 +778,7 @@ class Window(QMainWindow, Ui_MainWindow):
with open(path, "w") as f:
with Session() as session:
for playdate in Playdates.played_after(session, start_dt):
f.write(
f"{playdate.track.artist},{playdate.track.title}\n"
)
f.write(f"{playdate.track.artist},{playdate.track.title}\n")
def drop3db(self) -> None:
"""Drop music level by 3db if button checked"""
@ -867,16 +849,16 @@ class Window(QMainWindow, Ui_MainWindow):
playlist_id = self.visible_playlist_tab().playlist_id
with Session() as session:
# Get output filename
playlist = session.get(Playlists, playlist_id)
if not playlist:
return
pathspec = QFileDialog.getSaveFileName(
self, 'Save Playlist',
self,
"Save Playlist",
directory=f"{playlist.name}.m3u",
filter="M3U files (*.m3u);;All files (*.*)"
filter="M3U files (*.m3u);;All files (*.*)",
)
if not pathspec:
return
@ -923,10 +905,7 @@ class Window(QMainWindow, Ui_MainWindow):
times a second; this function has much better resolution.
"""
if (
self.current_track.track_id is None or
self.current_track.start_time is None
):
if self.current_track.track_id is None or self.current_track.start_time is None:
return 0
now = datetime.now()
@ -965,16 +944,16 @@ class Window(QMainWindow, Ui_MainWindow):
txt = ""
tags = helpers.get_tags(fname)
new_tracks.append(fname)
title = tags['title']
artist = tags['artist']
title = tags["title"]
artist = tags["artist"]
count = 0
possible_matches = Tracks.search_titles(session, title)
if possible_matches:
txt += 'Similar to new track '
txt += "Similar to new track "
txt += f'"{title}" by "{artist} ({fname})":\n\n'
for track in possible_matches:
txt += f' "{track.title}" by {track.artist}'
txt += f' ({track.path})\n\n'
txt += f" ({track.path})\n\n"
count += 1
if count >= Config.MAX_IMPORT_MATCHES:
txt += "\nThere are more similar-looking tracks"
@ -987,7 +966,7 @@ class Window(QMainWindow, Ui_MainWindow):
"Possible duplicates",
txt,
QMessageBox.StandardButton.Ok,
QMessageBox.StandardButton.Cancel
QMessageBox.StandardButton.Cancel,
)
if result == QMessageBox.StandardButton.Cancel:
return
@ -1005,9 +984,7 @@ class Window(QMainWindow, Ui_MainWindow):
self, "Import error", "Error importing " + msg
)
)
self.worker.importing.connect(
lambda msg: self.statusbar.showMessage(msg, 5000)
)
self.worker.importing.connect(lambda msg: self.statusbar.showMessage(msg, 5000))
self.worker.finished.connect(self.import_complete)
self.import_thread.start()
@ -1057,8 +1034,9 @@ class Window(QMainWindow, Ui_MainWindow):
if record and record.f_int >= 0:
self.tabPlaylist.setCurrentIndex(record.f_int)
def move_playlist_rows(self, session: scoped_session,
playlistrows: List[PlaylistRows]) -> None:
def move_playlist_rows(
self, session: scoped_session, playlistrows: List[PlaylistRows]
) -> None:
"""
Move passed playlist rows to another playlist
@ -1071,14 +1049,15 @@ class Window(QMainWindow, Ui_MainWindow):
"""
# Remove current/next rows from list
plrs_to_move = [plr for plr in playlistrows if
plr.id not in
[self.current_track.plr_id,
self.next_track.plr_id]
plrs_to_move = [
plr
for plr in playlistrows
if plr.id not in [self.current_track.plr_id, self.next_track.plr_id]
]
rows_to_delete = [plr.plr_rownum for plr in plrs_to_move
if plr.plr_rownum is not None]
rows_to_delete = [
plr.plr_rownum for plr in plrs_to_move if plr.plr_rownum is not None
]
if not rows_to_delete:
return
@ -1100,8 +1079,7 @@ class Window(QMainWindow, Ui_MainWindow):
destination_playlist_id = dlg.playlist.id
# Update destination playlist in the database
last_row = PlaylistRows.get_last_used_row(session,
destination_playlist_id)
last_row = PlaylistRows.get_last_used_row(session, destination_playlist_id)
if last_row is not None:
next_row = last_row + 1
else:
@ -1135,8 +1113,9 @@ class Window(QMainWindow, Ui_MainWindow):
"""
with Session() as session:
selected_plrs = (
self.visible_playlist_tab().get_selected_playlistrows(session))
selected_plrs = self.visible_playlist_tab().get_selected_playlistrows(
session
)
if not selected_plrs:
return
@ -1155,11 +1134,9 @@ class Window(QMainWindow, Ui_MainWindow):
playlist_id = self.visible_playlist_tab().playlist_id
with Session() as session:
unplayed_plrs = PlaylistRows.get_unplayed_rows(
session, playlist_id)
if helpers.ask_yes_no("Move tracks",
f"Move {len(unplayed_plrs)} tracks:"
" Are you sure?"
unplayed_plrs = PlaylistRows.get_unplayed_rows(session, playlist_id)
if helpers.ask_yes_no(
"Move tracks", f"Move {len(unplayed_plrs)} tracks:" " Are you sure?"
):
self.move_playlist_rows(session, unplayed_plrs)
@ -1168,8 +1145,7 @@ class Window(QMainWindow, Ui_MainWindow):
with Session() as session:
templates = Playlists.get_all_templates(session)
dlg = SelectPlaylistDialog(self, playlists=templates,
session=session)
dlg = SelectPlaylistDialog(self, playlists=templates, session=session)
dlg.exec()
template = dlg.playlist
if template:
@ -1177,7 +1153,8 @@ class Window(QMainWindow, Ui_MainWindow):
if not playlist_name:
return
playlist = Playlists.create_playlist_from_template(
session, template, playlist_name)
session, template, playlist_name
)
if not playlist:
return
tab_index = self.create_playlist_tab(session, playlist)
@ -1188,8 +1165,7 @@ class Window(QMainWindow, Ui_MainWindow):
with Session() as session:
playlists = Playlists.get_closed(session)
dlg = SelectPlaylistDialog(self, playlists=playlists,
session=session)
dlg = SelectPlaylistDialog(self, playlists=playlists, session=session)
dlg.exec()
playlist = dlg.playlist
if playlist:
@ -1217,8 +1193,9 @@ class Window(QMainWindow, Ui_MainWindow):
with Session() as session:
# Create space in destination playlist
PlaylistRows.move_rows_down(session, dst_playlist_id,
dst_row, len(self.selected_plrs))
PlaylistRows.move_rows_down(
session, dst_playlist_id, dst_row, len(self.selected_plrs)
)
session.commit()
# Update plrs
@ -1240,7 +1217,8 @@ class Window(QMainWindow, Ui_MainWindow):
# Update display
self.visible_playlist_tab().populate_display(
session, dst_playlist_id, scroll_to_top=False)
session, dst_playlist_id, scroll_to_top=False
)
# If source playlist is not destination playlist, fixup row
# numbers and update display
@ -1250,13 +1228,13 @@ class Window(QMainWindow, Ui_MainWindow):
# will be re-populated when it is opened)
source_playlist_tab = None
for tab in range(self.tabPlaylist.count()):
if self.tabPlaylist.widget(tab).playlist_id == \
src_playlist_id:
if self.tabPlaylist.widget(tab).playlist_id == src_playlist_id:
source_playlist_tab = self.tabPlaylist.widget(tab)
break
if source_playlist_tab:
source_playlist_tab.populate_display(
session, src_playlist_id, scroll_to_top=False)
session, src_playlist_id, scroll_to_top=False
)
# Reset so rows can't be repasted
self.selected_plrs = None
@ -1305,8 +1283,7 @@ class Window(QMainWindow, Ui_MainWindow):
# Set current track playlist_tab colour
current_tab = self.current_track.playlist_tab
if current_tab:
self.set_tab_colour(
current_tab, QColor(Config.COLOUR_CURRENT_TAB))
self.set_tab_colour(current_tab, QColor(Config.COLOUR_CURRENT_TAB))
# Restore volume if -3dB active
if self.btnDrop3db.isChecked():
@ -1356,8 +1333,7 @@ class Window(QMainWindow, Ui_MainWindow):
if self.btnPreview.isChecked():
# Get track path for first selected track if there is one
track_path = (
self.visible_playlist_tab().get_selected_row_track_path())
track_path = self.visible_playlist_tab().get_selected_row_track_path()
if not track_path:
# Otherwise get path to next track to play
track_path = self.next_track.path
@ -1405,7 +1381,6 @@ class Window(QMainWindow, Ui_MainWindow):
if self.current_track.track_id:
playing_track = self.current_track
with Session() as session:
# Set next plr to be track to resume
if not self.previous_track.plr_id:
return
@ -1413,8 +1388,9 @@ class Window(QMainWindow, Ui_MainWindow):
return
# Resume last track
self.set_next_plr_id(self.previous_track.plr_id,
self.previous_track.playlist_tab)
self.set_next_plr_id(
self.previous_track.plr_id, self.previous_track.playlist_tab
)
self.play_next(self.previous_track_position)
# If a track was playing when we were called, get details to
@ -1424,16 +1400,13 @@ class Window(QMainWindow, Ui_MainWindow):
return
if not playing_track.playlist_tab:
return
self.set_next_plr_id(playing_track.plr_id,
playing_track.playlist_tab)
self.set_next_plr_id(playing_track.plr_id, playing_track.playlist_tab)
def save_as_template(self) -> None:
"""Save current playlist as template"""
with Session() as session:
template_names = [
a.name for a in Playlists.get_all_templates(session)
]
template_names = [a.name for a in Playlists.get_all_templates(session)]
while True:
# Get name for new template
@ -1448,13 +1421,12 @@ class Window(QMainWindow, Ui_MainWindow):
template_name = dlg.textValue()
if template_name not in template_names:
break
helpers.show_warning(self,
"Duplicate template",
"Template name already in use"
helpers.show_warning(
self, "Duplicate template", "Template name already in use"
)
Playlists.save_as_template(
session, self.visible_playlist_tab().playlist_id, template_name
)
Playlists.save_as_template(session,
self.visible_playlist_tab().playlist_id,
template_name)
helpers.show_OK(self, "Template", "Template saved")
def search_playlist(self) -> None:
@ -1535,8 +1507,7 @@ class Window(QMainWindow, Ui_MainWindow):
self.tabPlaylist.setCurrentWidget(self.next_track.playlist_tab)
self.tabPlaylist.currentWidget().scroll_next_to_top()
def solicit_playlist_name(self,
default: Optional[str] = "") -> Optional[str]:
def solicit_playlist_name(self, default: Optional[str] = "") -> Optional[str]:
"""Get name of playlist from user"""
dlg = QInputDialog(self)
@ -1581,11 +1552,13 @@ class Window(QMainWindow, Ui_MainWindow):
# Reset playlist_tab colour
if self.current_track.playlist_tab:
if self.current_track.playlist_tab == self.next_track.playlist_tab:
self.set_tab_colour(self.current_track.playlist_tab,
QColor(Config.COLOUR_NEXT_TAB))
self.set_tab_colour(
self.current_track.playlist_tab, QColor(Config.COLOUR_NEXT_TAB)
)
else:
self.set_tab_colour(self.current_track.playlist_tab,
QColor(Config.COLOUR_NORMAL_TAB))
self.set_tab_colour(
self.current_track.playlist_tab, QColor(Config.COLOUR_NORMAL_TAB)
)
# Run end-of-track actions
self.end_of_track_actions()
@ -1612,8 +1585,9 @@ class Window(QMainWindow, Ui_MainWindow):
self.set_next_plr_id(selected_plr_ids[0], playlist_tab)
def set_next_plr_id(self, next_plr_id: Optional[int],
playlist_tab: PlaylistTab) -> None:
def set_next_plr_id(
self, next_plr_id: Optional[int], playlist_tab: PlaylistTab
) -> None:
"""
Set passed plr_id as next track to play, or clear next track if None
@ -1636,8 +1610,9 @@ class Window(QMainWindow, Ui_MainWindow):
# Tell playlist tabs to update their 'next track' highlighting
# Args must both be ints, so use zero for no next track
self.signals.set_next_track_signal.emit(old_next_track.plr_id,
next_plr_id or 0)
self.signals.set_next_track_signal.emit(
old_next_track.plr_id, next_plr_id or 0
)
# Update headers
self.update_headers()
@ -1650,12 +1625,15 @@ class Window(QMainWindow, Ui_MainWindow):
# because it isn't quick
if self.next_track.title:
QTimer.singleShot(
1, lambda: self.tabInfolist.open_in_wikipedia(
self.next_track.title)
1,
lambda: self.tabInfolist.open_in_wikipedia(
self.next_track.title
),
)
def _set_next_track_playlist_tab_colours(
self, old_next_track: Optional[PlaylistTrack]) -> None:
self, old_next_track: Optional[PlaylistTrack]
) -> None:
"""
Set playlist tab colour for next track. self.next_track needs
to be set before calling.
@ -1664,14 +1642,14 @@ class Window(QMainWindow, Ui_MainWindow):
# If the original next playlist tab isn't the same as the
# new one or the current track, it needs its colour reset.
if (
old_next_track and
old_next_track.playlist_tab and
old_next_track.playlist_tab not in [
self.next_track.playlist_tab,
self.current_track.playlist_tab
]):
self.set_tab_colour(old_next_track.playlist_tab,
QColor(Config.COLOUR_NORMAL_TAB))
old_next_track
and old_next_track.playlist_tab
and old_next_track.playlist_tab
not in [self.next_track.playlist_tab, self.current_track.playlist_tab]
):
self.set_tab_colour(
old_next_track.playlist_tab, QColor(Config.COLOUR_NORMAL_TAB)
)
# If the new next playlist tab isn't the same as the
# old one or the current track, it needs its colour set.
if old_next_track:
@ -1679,14 +1657,14 @@ class Window(QMainWindow, Ui_MainWindow):
else:
old_tab = None
if (
self.next_track and
self.next_track.playlist_tab and
self.next_track.playlist_tab not in [
old_tab,
self.current_track.playlist_tab
]):
self.set_tab_colour(self.next_track.playlist_tab,
QColor(Config.COLOUR_NEXT_TAB))
self.next_track
and self.next_track.playlist_tab
and self.next_track.playlist_tab
not in [old_tab, self.current_track.playlist_tab]
):
self.set_tab_colour(
self.next_track.playlist_tab, QColor(Config.COLOUR_NEXT_TAB)
)
def tick(self) -> None:
"""
@ -1713,9 +1691,9 @@ class Window(QMainWindow, Ui_MainWindow):
# Update volume fade curve
if (
self.current_track.track_id and
self.current_track.fade_graph and
self.current_track.start_time
self.current_track.track_id
and self.current_track.fade_graph
and self.current_track.start_time
):
play_time = (
datetime.now() - self.current_track.start_time
@ -1727,8 +1705,7 @@ class Window(QMainWindow, Ui_MainWindow):
Called every 500ms
"""
self.lblTOD.setText(datetime.now().strftime(
Config.TOD_TIME_FORMAT))
self.lblTOD.setText(datetime.now().strftime(Config.TOD_TIME_FORMAT))
# Update carts
self.cart_tick()
@ -1748,15 +1725,19 @@ class Window(QMainWindow, Ui_MainWindow):
# player.is_playing() returning True, so assume playing if less
# than Config.PLAY_SETTLE microseconds have passed since
# starting play.
if self.music.player and self.current_track.start_time and (
self.music.player.is_playing() or
(datetime.now() - self.current_track.start_time)
< timedelta(microseconds=Config.PLAY_SETTLE)):
if (
self.music.player
and self.current_track.start_time
and (
self.music.player.is_playing()
or (datetime.now() - self.current_track.start_time)
< timedelta(microseconds=Config.PLAY_SETTLE)
)
):
playtime = self.get_playtime()
time_to_fade = (self.current_track.fade_at - playtime)
time_to_silence = (
self.current_track.silence_at - playtime)
time_to_end = (self.current_track.duration - playtime)
time_to_fade = self.current_track.fade_at - playtime
time_to_silence = self.current_track.silence_at - playtime
time_to_end = self.current_track.duration - playtime
# Elapsed time
self.label_elapsed_timer.setText(helpers.ms_to_mmss(playtime))
@ -1787,9 +1768,7 @@ class Window(QMainWindow, Ui_MainWindow):
self.frame_silent.setStyleSheet("")
self.frame_fade.setStyleSheet("")
self.label_silent_timer.setText(
helpers.ms_to_mmss(time_to_silence)
)
self.label_silent_timer.setText(helpers.ms_to_mmss(time_to_silence))
# Time to end
self.label_end_timer.setText(helpers.ms_to_mmss(time_to_end))
@ -1834,8 +1813,9 @@ class Window(QMainWindow, Ui_MainWindow):
class CartDialog(QDialog):
"""Edit cart details"""
def __init__(self, musicmuster: Window, session: scoped_session,
cart: Carts, *args, **kwargs) -> None:
def __init__(
self, musicmuster: Window, session: scoped_session, cart: Carts, *args, **kwargs
) -> None:
"""
Manage carts
"""
@ -1871,8 +1851,14 @@ class CartDialog(QDialog):
class DbDialog(QDialog):
"""Select track from database"""
def __init__(self, musicmuster: Window, session: scoped_session,
get_one_track: bool = False, *args, **kwargs) -> None:
def __init__(
self,
musicmuster: Window,
session: scoped_session,
get_one_track: bool = False,
*args,
**kwargs,
) -> None:
"""
Subclassed QDialog to manage track selection
@ -1911,17 +1897,16 @@ class DbDialog(QDialog):
record = Settings.get_int_settings(self.session, "dbdialog_height")
if record.f_int != self.height():
record.update(self.session, {'f_int': self.height()})
record.update(self.session, {"f_int": self.height()})
record = Settings.get_int_settings(self.session, "dbdialog_width")
if record.f_int != self.width():
record.update(self.session, {'f_int': self.width()})
record.update(self.session, {"f_int": self.width()})
def add_selected(self) -> None:
"""Handle Add button"""
if (not self.ui.matchList.selectedItems() and
not self.ui.txtNote.text()):
if not self.ui.matchList.selectedItems() and not self.ui.txtNote.text():
return
track = None
@ -1946,10 +1931,12 @@ class DbDialog(QDialog):
if track:
self.musicmuster.visible_playlist_tab().insert_track(
self.session, track, note=self.ui.txtNote.text())
self.session, track, note=self.ui.txtNote.text()
)
else:
self.musicmuster.visible_playlist_tab().insert_header(
self.session, note=self.ui.txtNote.text())
self.session, note=self.ui.txtNote.text()
)
# TODO: this shouldn't be needed as insert_track() saves
# playlist
@ -2041,11 +2028,11 @@ class SelectPlaylistDialog(QDialog):
self.session = session
self.playlist = None
record = Settings.get_int_settings(
self.session, "select_playlist_dialog_width")
record = Settings.get_int_settings(self.session, "select_playlist_dialog_width")
width = record.f_int or 800
record = Settings.get_int_settings(
self.session, "select_playlist_dialog_height")
self.session, "select_playlist_dialog_height"
)
height = record.f_int or 600
self.resize(width, height)
@ -2057,14 +2044,14 @@ class SelectPlaylistDialog(QDialog):
def __del__(self): # review
record = Settings.get_int_settings(
self.session, "select_playlist_dialog_height")
self.session, "select_playlist_dialog_height"
)
if record.f_int != self.height():
record.update(self.session, {'f_int': self.height()})
record.update(self.session, {"f_int": self.height()})
record = Settings.get_int_settings(
self.session, "select_playlist_dialog_width")
record = Settings.get_int_settings(self.session, "select_playlist_dialog_width")
if record.f_int != self.width():
record.update(self.session, {'f_int': self.width()})
record.update(self.session, {"f_int": self.width()})
def list_doubleclick(self, entry): # review
self.playlist = entry.data(Qt.ItemDataRole.UserRole)
@ -2086,12 +2073,22 @@ if __name__ == "__main__":
p = argparse.ArgumentParser()
# Only allow at most one option to be specified
group = p.add_mutually_exclusive_group()
group.add_argument('-b', '--bitrates',
action="store_true", dest="update_bitrates",
default=False, help="Update bitrates in database")
group.add_argument('-c', '--check-database',
action="store_true", dest="check_db",
default=False, help="Check and report on database")
group.add_argument(
"-b",
"--bitrates",
action="store_true",
dest="update_bitrates",
default=False,
help="Update bitrates in database",
)
group.add_argument(
"-c",
"--check-database",
action="store_true",
dest="check_db",
default=False,
help="Check and report on database",
)
args = p.parse_args()
# Run as required
@ -2111,13 +2108,16 @@ if __name__ == "__main__":
app = QApplication(sys.argv)
# PyQt6 defaults to a grey for labels
palette = app.palette()
palette.setColor(QPalette.ColorRole.WindowText,
QColor(Config.COLOUR_LABEL_TEXT))
palette.setColor(
QPalette.ColorRole.WindowText, QColor(Config.COLOUR_LABEL_TEXT)
)
# Set colours that will be used by playlist row stripes
palette.setColor(QPalette.ColorRole.Base,
QColor(Config.COLOUR_EVEN_PLAYLIST))
palette.setColor(QPalette.ColorRole.AlternateBase,
QColor(Config.COLOUR_ODD_PLAYLIST))
palette.setColor(
QPalette.ColorRole.Base, QColor(Config.COLOUR_EVEN_PLAYLIST)
)
palette.setColor(
QPalette.ColorRole.AlternateBase, QColor(Config.COLOUR_ODD_PLAYLIST)
)
app.setPalette(palette)
win = Window()
win.show()
@ -2129,8 +2129,12 @@ if __name__ == "__main__":
from helpers import send_mail
msg = stackprinter.format(exc)
send_mail(Config.ERRORS_TO, Config.ERRORS_FROM,
"Exception from musicmuster", msg)
send_mail(
Config.ERRORS_TO,
Config.ERRORS_FROM,
"Exception from musicmuster",
msg,
)
print("\033[1;31;47mUnhandled exception starts")
stackprinter.show(style="darkbg")

File diff suppressed because it is too large Load Diff

View File

@ -10,11 +10,7 @@ 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,33 +74,31 @@ 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}"
@ -114,8 +108,9 @@ def main():
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(
@ -22,6 +22,7 @@ def connection():
@pytest.fixture(scope="session")
def setup_database(connection):
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)
@ -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)

22
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"))
@ -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