Compare commits

...

30 Commits

Author SHA1 Message Date
Keith Edmunds
256de377cf Update environment 2025-02-06 12:54:01 +00:00
Keith Edmunds
a3c405912a Fixup logging when no module log.debug output specifed 2025-02-05 18:07:22 +00:00
Keith Edmunds
4e73ea6e6a Black formatting 2025-02-05 17:46:16 +00:00
Keith Edmunds
c9b45848dd Refine and fix file_importer tests 2025-02-05 17:43:38 +00:00
Keith Edmunds
fd0d8b15f7 Poetry only for dependency management 2025-02-02 17:54:44 +00:00
Keith Edmunds
7d0e1c809f Update environment 2025-02-02 17:52:15 +00:00
Keith Edmunds
5cae8e4b19 File importer - more tests 2025-02-01 22:11:01 +00:00
Keith Edmunds
8177e03387 Tweak pyproject.toml for v2 2025-01-31 10:00:55 +00:00
Keith Edmunds
f4923314d8 Remove spurious logging. Start 10ms timer at a better time.
The 10ms timer was paused for five seconds when starting a track to
avoid a short pause (issue #223). That fixed the problem. However, it
doesn't need to be started until the fade graph starts changing, so we
now don't start it until then. It's possible that this may help the
occasional 'slow to refresh after moving tracks' issue that has been
seen which may be caused by timer ticks piling up and needing to be
serviced.
2025-01-31 09:55:21 +00:00
Keith Edmunds
24787578bc Tweaks to FileImporter and tests 2025-01-31 09:55:21 +00:00
Keith Edmunds
1f4e7cb054 Cleanup around new logging 2025-01-31 09:55:21 +00:00
Keith Edmunds
92e1a1cac8 New FileImporter working, tests to be written 2025-01-31 09:55:21 +00:00
Keith Edmunds
52a773176c Refine module and function logging to stderr 2025-01-31 09:55:21 +00:00
Keith Edmunds
cedc7180d4 WIP: FileImporter runs but needs more testing 2025-01-31 09:55:21 +00:00
Keith Edmunds
728ac0f8dc Add function name to console log output 2025-01-31 09:55:21 +00:00
Keith Edmunds
4741c1d33f Make failure to connect to OBS a warning, not error 2025-01-31 09:55:21 +00:00
Keith Edmunds
aa52f33d58 Fixup new logging 2025-01-31 09:55:21 +00:00
Keith Edmunds
2f18ef5f44 Cascade deleted tracks to playlist_rows and playdates 2025-01-31 09:55:21 +00:00
Keith Edmunds
4927f237ab Use locking when creating singleton 2025-01-31 09:55:21 +00:00
Keith Edmunds
d3a709642b Migrate pyproject.toml to v2 2025-01-31 09:54:14 +00:00
Keith Edmunds
3afcfd5856 Move to YAML-configured logging 2025-01-27 12:13:13 +00:00
Keith Edmunds
342c0a2285 Add type hints for pyyaml 2025-01-27 12:13:13 +00:00
Keith Edmunds
8161fb00b3 Add pyyaml; update poetry environment 2025-01-27 12:13:13 +00:00
Keith Edmunds
f9943dc1c4 WIP file_importer rewrite, one test written and working 2025-01-21 21:26:06 +00:00
Keith Edmunds
b2000169b3 Add index to notecolours 2025-01-18 11:02:56 +00:00
Keith Edmunds
5e72f17793 Clean up type hints 2025-01-17 21:35:29 +00:00
Keith Edmunds
4a4058d211 Import rewrite WIP 2025-01-13 15:29:50 +00:00
Keith Edmunds
3b71041b66 Remove profiling calls (again) 2025-01-10 20:37:49 +00:00
Keith Edmunds
d30bf49c88 Don't select unplayable track as next track 2025-01-10 20:27:26 +00:00
Keith Edmunds
3a3b1b712d Much improved file importer 2025-01-10 19:50:53 +00:00
20 changed files with 1999 additions and 1194 deletions

View File

@ -1,9 +1,10 @@
# Standard library imports # Standard library imports
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass
from enum import auto, Enum from enum import auto, Enum
import functools import functools
import threading
from typing import NamedTuple from typing import NamedTuple
# Third party imports # Third party imports
@ -34,12 +35,18 @@ def singleton(cls):
""" """
Make a class a Singleton class (see Make a class a Singleton class (see
https://realpython.com/primer-on-python-decorators/#creating-singletons) https://realpython.com/primer-on-python-decorators/#creating-singletons)
Added locking.
""" """
lock = threading.Lock()
@functools.wraps(cls) @functools.wraps(cls)
def wrapper_singleton(*args, **kwargs): def wrapper_singleton(*args, **kwargs):
if not wrapper_singleton.instance: if wrapper_singleton.instance is None:
wrapper_singleton.instance = cls(*args, **kwargs) with lock:
if wrapper_singleton.instance is None: # Check still None
wrapper_singleton.instance = cls(*args, **kwargs)
return wrapper_singleton.instance return wrapper_singleton.instance
wrapper_singleton.instance = None wrapper_singleton.instance = None
@ -94,10 +101,10 @@ class MusicMusterSignals(QObject):
class Tags(NamedTuple): class Tags(NamedTuple):
artist: str artist: str = ""
title: str title: str = ""
bitrate: int bitrate: int = 0
duration: int duration: int = 0
class TrackInfo(NamedTuple): class TrackInfo(NamedTuple):

View File

@ -2,7 +2,6 @@
import datetime as dt import datetime as dt
import logging import logging
import os import os
from typing import List, Optional
# PyQt imports # PyQt imports
@ -35,8 +34,6 @@ class Config(object):
COLOUR_UNREADABLE = "#dc3545" COLOUR_UNREADABLE = "#dc3545"
COLOUR_WARNING_TIMER = "#ffc107" COLOUR_WARNING_TIMER = "#ffc107"
DBFS_SILENCE = -50 DBFS_SILENCE = -50
DEBUG_FUNCTIONS: List[Optional[str]] = []
DEBUG_MODULES: List[Optional[str]] = []
DEFAULT_COLUMN_WIDTH = 200 DEFAULT_COLUMN_WIDTH = 200
DISPLAY_SQL = False DISPLAY_SQL = False
DO_NOT_IMPORT = "Do not import" DO_NOT_IMPORT = "Do not import"
@ -51,6 +48,10 @@ class Config(object):
FADEOUT_DB = -10 FADEOUT_DB = -10
FADEOUT_SECONDS = 5 FADEOUT_SECONDS = 5
FADEOUT_STEPS_PER_SECOND = 5 FADEOUT_STEPS_PER_SECOND = 5
FUZZYMATCH_MINIMUM_LIST = 60.0
FUZZYMATCH_MINIMUM_SELECT_ARTIST = 80.0
FUZZYMATCH_MINIMUM_SELECT_TITLE = 80.0
FUZZYMATCH_SHOW_SCORES = True
HEADER_ARTIST = "Artist" HEADER_ARTIST = "Artist"
HEADER_BITRATE = "bps" HEADER_BITRATE = "bps"
HEADER_DURATION = "Length" HEADER_DURATION = "Length"
@ -62,8 +63,8 @@ class Config(object):
HEADER_START_TIME = "Start" HEADER_START_TIME = "Start"
HEADER_TITLE = "Title" HEADER_TITLE = "Title"
HIDE_AFTER_PLAYING_OFFSET = 5000 HIDE_AFTER_PLAYING_OFFSET = 5000
HIDE_PLAYED_MODE_TRACKS = "TRACKS"
HIDE_PLAYED_MODE_SECTIONS = "SECTIONS" HIDE_PLAYED_MODE_SECTIONS = "SECTIONS"
HIDE_PLAYED_MODE_TRACKS = "TRACKS"
IMPORT_AS_NEW = "Import as new track" IMPORT_AS_NEW = "Import as new track"
INFO_TAB_TITLE_LENGTH = 15 INFO_TAB_TITLE_LENGTH = 15
INTRO_SECONDS_FORMAT = ".1f" INTRO_SECONDS_FORMAT = ".1f"
@ -79,10 +80,10 @@ class Config(object):
MAIL_USERNAME = os.environ.get("MAIL_USERNAME") MAIL_USERNAME = os.environ.get("MAIL_USERNAME")
MAIL_USE_TLS = os.environ.get("MAIL_USE_TLS") is not None MAIL_USE_TLS = os.environ.get("MAIL_USE_TLS") is not None
MAX_IMPORT_MATCHES = 5 MAX_IMPORT_MATCHES = 5
MAX_IMPORT_THREADS = 3
MAX_INFO_TABS = 5 MAX_INFO_TABS = 5
MAX_MISSING_FILES_TO_REPORT = 10 MAX_MISSING_FILES_TO_REPORT = 10
MILLISECOND_SIGFIGS = 0 MILLISECOND_SIGFIGS = 0
MINIMUM_FUZZYMATCH = 60.0
MINIMUM_ROW_HEIGHT = 30 MINIMUM_ROW_HEIGHT = 30
NO_TEMPLATE_NAME = "None" NO_TEMPLATE_NAME = "None"
NOTE_TIME_FORMAT = "%H:%M" NOTE_TIME_FORMAT = "%H:%M"

View File

@ -1,5 +1,5 @@
# Standard library imports # Standard library imports
from typing import List, Optional from typing import Optional
import datetime as dt import datetime as dt
# PyQt imports # PyQt imports
@ -27,7 +27,7 @@ class NoteColoursTable(Model):
__tablename__ = "notecolours" __tablename__ = "notecolours"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
substring: Mapped[str] = mapped_column(String(256), index=False) substring: Mapped[str] = mapped_column(String(256), index=True)
colour: Mapped[str] = mapped_column(String(21), index=False) colour: Mapped[str] = mapped_column(String(21), index=False)
enabled: Mapped[bool] = mapped_column(default=True, index=True) enabled: Mapped[bool] = mapped_column(default=True, index=True)
foreground: Mapped[Optional[str]] = mapped_column(String(21), index=False) foreground: Mapped[Optional[str]] = mapped_column(String(21), index=False)
@ -73,7 +73,7 @@ class PlaylistsTable(Model):
tab: Mapped[Optional[int]] = mapped_column(default=None) tab: Mapped[Optional[int]] = mapped_column(default=None)
open: Mapped[bool] = mapped_column(default=False) open: Mapped[bool] = mapped_column(default=False)
is_template: Mapped[bool] = mapped_column(default=False) is_template: Mapped[bool] = mapped_column(default=False)
rows: Mapped[List["PlaylistRowsTable"]] = relationship( rows: Mapped[list["PlaylistRowsTable"]] = relationship(
"PlaylistRowsTable", "PlaylistRowsTable",
back_populates="playlist", back_populates="playlist",
cascade="all, delete-orphan", cascade="all, delete-orphan",
@ -146,13 +146,16 @@ class TracksTable(Model):
start_gap: Mapped[int] = mapped_column(index=False) start_gap: Mapped[int] = mapped_column(index=False)
title: Mapped[str] = mapped_column(String(256), index=True) title: Mapped[str] = mapped_column(String(256), index=True)
playlistrows: Mapped[List[PlaylistRowsTable]] = relationship( playlistrows: Mapped[list[PlaylistRowsTable]] = relationship(
"PlaylistRowsTable", back_populates="track" "PlaylistRowsTable",
back_populates="track",
cascade="all, delete-orphan",
) )
playlists = association_proxy("playlistrows", "playlist") playlists = association_proxy("playlistrows", "playlist")
playdates: Mapped[List[PlaydatesTable]] = relationship( playdates: Mapped[list[PlaydatesTable]] = relationship(
"PlaydatesTable", "PlaydatesTable",
back_populates="track", back_populates="track",
cascade="all, delete-orphan",
lazy="joined", lazy="joined",
) )

View File

@ -1,6 +1,5 @@
# Standard library imports # Standard library imports
from typing import Optional from typing import Optional
import os
# PyQt imports # PyQt imports
from PyQt6.QtCore import QEvent, Qt from PyQt6.QtCore import QEvent, Qt
@ -9,27 +8,22 @@ from PyQt6.QtWidgets import (
QDialog, QDialog,
QListWidgetItem, QListWidgetItem,
QMainWindow, QMainWindow,
QTableWidgetItem,
) )
# Third party imports # Third party imports
import pydymenu # type: ignore
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
# App imports # App imports
from classes import MusicMusterSignals from classes import MusicMusterSignals
from config import Config
from helpers import ( from helpers import (
ask_yes_no, ask_yes_no,
get_relative_date, get_relative_date,
get_tags,
ms_to_mmss, ms_to_mmss,
show_warning,
) )
from log import log from log import log
from models import db, Settings, Tracks from models import Settings, Tracks
from playlistmodel import PlaylistModel from playlistmodel import PlaylistModel
from ui import dlg_TrackSelect_ui, dlg_replace_files_ui from ui import dlg_TrackSelect_ui
class TrackSelectDialog(QDialog): class TrackSelectDialog(QDialog):

File diff suppressed because it is too large Load Diff

View File

@ -10,7 +10,7 @@ import ssl
import tempfile import tempfile
# PyQt imports # PyQt imports
from PyQt6.QtWidgets import QMainWindow, QMessageBox from PyQt6.QtWidgets import QMainWindow, QMessageBox, QWidget
# Third party imports # Third party imports
from mutagen.flac import FLAC # type: ignore from mutagen.flac import FLAC # type: ignore
@ -200,9 +200,17 @@ def get_tags(path: str) -> Tags:
try: try:
tag = TinyTag.get(path) tag = TinyTag.get(path)
except FileNotFoundError: except FileNotFoundError:
raise ApplicationError(f"File not found: get_tags({path=})") raise ApplicationError(f"File not found: {path}")
except TinyTagException: except TinyTagException:
raise ApplicationError(f"Can't read tags: get_tags({path=})") raise ApplicationError(f"Can't read tags in {path}")
if (
tag.title is None
or tag.artist is None
or tag.bitrate is None
or tag.duration is None
):
raise ApplicationError(f"Missing tags in {path}")
return Tags( return Tags(
title=tag.title, title=tag.title,
@ -392,10 +400,16 @@ def set_track_metadata(track: Tracks) -> None:
setattr(track, tag_key, getattr(tags, tag_key)) setattr(track, tag_key, getattr(tags, tag_key))
def show_OK(parent: QMainWindow, title: str, msg: str) -> None: def show_OK(title: str, msg: str, parent: Optional[QWidget] = None) -> None:
"""Display a message to user""" """Display a message to user"""
QMessageBox.information(parent, title, msg, buttons=QMessageBox.StandardButton.Ok) dlg = QMessageBox(parent)
dlg.setIcon(QMessageBox.Icon.Information)
dlg.setWindowTitle(title)
dlg.setText(msg)
dlg.setStandardButtons(QMessageBox.StandardButton.Ok)
_ = dlg.exec()
def show_warning(parent: Optional[QMainWindow], title: str, msg: str) -> None: def show_warning(parent: Optional[QMainWindow], title: str, msg: str) -> None:

View File

@ -1,56 +1,79 @@
#!/usr/bin/python3 #!/usr/bin/env python3
# Standard library imports # Standard library imports
from collections import defaultdict
import logging import logging
import logging.config
import logging.handlers import logging.handlers
import os import os
import sys import sys
from traceback import print_exception from traceback import print_exception
import yaml
# PyQt imports # PyQt imports
# Third party imports # Third party imports
import colorlog
import stackprinter # type: ignore import stackprinter # type: ignore
# App imports # App imports
from config import Config from config import Config
class FunctionFilter(logging.Filter):
"""Filter to allow category-based logging to stderr."""
def __init__(self, module_functions: dict[str, list[str]]):
super().__init__()
self.modules: list[str] = []
self.functions: defaultdict[str, list[str]] = defaultdict(list)
if module_functions:
for module in module_functions.keys():
if module_functions[module]:
for function in module_functions[module]:
self.functions[module].append(function)
else:
self.modules.append(module)
def filter(self, record: logging.LogRecord) -> bool:
if not getattr(record, "levelname", None) == "DEBUG":
# Only prcess DEBUG messages
return False
module = getattr(record, "module", None)
if not module:
# No module in record
return False
# Process if this is a module we're tracking
if module in self.modules:
return True
# Process if this is a function we're tracking
if getattr(record, "funcName", None) in self.functions[module]:
return True
return False
class LevelTagFilter(logging.Filter): class LevelTagFilter(logging.Filter):
"""Add leveltag""" """Add leveltag"""
def filter(self, record: logging.LogRecord) -> bool: def filter(self, record: logging.LogRecord) -> bool:
# Extract the first character of the level name # Extract the first character of the level name
record.leveltag = record.levelname[0] record.leveltag = record.levelname[0]
# We never actually filter messages out, just add an extra field
# We never actually filter messages out, just abuse filtering to add an # to the LogRecord
# extra field to the LogRecord
return True return True
# Load YAML logging configuration
with open("app/logging.yaml", "r") as f:
config = yaml.safe_load(f)
logging.config.dictConfig(config)
# Get logger
log = logging.getLogger(Config.LOG_NAME) log = logging.getLogger(Config.LOG_NAME)
log.setLevel(logging.DEBUG)
local_filter = LevelTagFilter()
# stderr
stderr = colorlog.StreamHandler()
stderr.setLevel(Config.LOG_LEVEL_STDERR)
stderr.addFilter(local_filter)
stderr_fmt = colorlog.ColoredFormatter(
"%(log_color)s[%(asctime)s] %(filename)s:%(lineno)s %(message)s", datefmt="%H:%M:%S"
)
stderr.setFormatter(stderr_fmt)
log.addHandler(stderr)
# syslog
syslog = logging.handlers.SysLogHandler(address="/dev/log")
syslog.setLevel(Config.LOG_LEVEL_SYSLOG)
syslog.addFilter(local_filter)
syslog_fmt = logging.Formatter(
"[%(name)s] %(filename)s:%(lineno)s %(leveltag)s: %(message)s"
)
syslog.setFormatter(syslog_fmt)
log.addHandler(syslog)
def log_uncaught_exceptions(type_, value, traceback): def log_uncaught_exceptions(type_, value, traceback):

52
app/logging.yaml Normal file
View File

@ -0,0 +1,52 @@
version: 1
disable_existing_loggers: True
formatters:
colored:
(): colorlog.ColoredFormatter
format: "%(log_color)s[%(asctime)s] %(filename)s.%(funcName)s:%(lineno)s %(blue)s%(message)s"
datefmt: "%H:%M:%S"
syslog:
format: "[%(name)s] %(filename)s:%(lineno)s %(leveltag)s: %(message)s"
filters:
leveltag:
(): log.LevelTagFilter
category_filter:
(): log.FunctionFilter
module_functions:
# Optionally additionally log some debug calls to stderr
# log all debug calls in a module:
# module-name: []
# log debug calls for some functions in a module:
# module-name:
# - function-name-1
# - function-name-2
handlers:
stderr:
class: colorlog.StreamHandler
level: INFO
formatter: colored
filters: [leveltag]
stream: ext://sys.stderr
syslog:
class: logging.handlers.SysLogHandler
level: DEBUG
formatter: syslog
filters: [leveltag]
address: "/dev/log"
debug_stderr:
class: colorlog.StreamHandler
level: DEBUG
formatter: colored
filters: [leveltag, category_filter]
stream: ext://sys.stderr
loggers:
musicmuster:
level: DEBUG
handlers: [stderr, syslog, debug_stderr]
propagate: false

29
app/logging_tester.py Executable file
View File

@ -0,0 +1,29 @@
#!/usr/bin/env python3
from log import log
# Testing
def fa():
log.debug("fa Debug message")
log.info("fa Info message")
log.warning("fa Warning message")
log.error("fa Error message")
log.critical("fa Critical message")
print()
def fb():
log.debug("fb Debug message")
log.info("fb Info message")
log.warning("fb Warning message")
log.error("fb Error message")
log.critical("fb Critical message")
print()
def testing():
fa()
fb()
testing()

View File

@ -1,7 +1,7 @@
# Standard library imports # Standard library imports
from __future__ import annotations from __future__ import annotations
from typing import List, Optional, Sequence from typing import Optional, Sequence
import datetime as dt import datetime as dt
import os import os
import re import re
@ -10,7 +10,6 @@ import sys
# PyQt imports # PyQt imports
# Third party imports # Third party imports
import line_profiler
from sqlalchemy import ( from sqlalchemy import (
bindparam, bindparam,
delete, delete,
@ -188,7 +187,7 @@ class Playlists(dbtables.PlaylistsTable):
session.commit() session.commit()
@staticmethod @staticmethod
def clear_tabs(session: Session, playlist_ids: List[int]) -> None: def clear_tabs(session: Session, playlist_ids: list[int]) -> None:
""" """
Make all tab records NULL Make all tab records NULL
""" """
@ -246,9 +245,7 @@ class Playlists(dbtables.PlaylistsTable):
"""Returns a list of all templates ordered by name""" """Returns a list of all templates ordered by name"""
return session.scalars( return session.scalars(
select(cls) select(cls).where(cls.is_template.is_(True)).order_by(cls.name)
.where(cls.is_template.is_(True))
.order_by(cls.name)
).all() ).all()
@classmethod @classmethod
@ -428,7 +425,7 @@ class PlaylistRows(dbtables.PlaylistRowsTable):
@classmethod @classmethod
def plrids_to_plrs( def plrids_to_plrs(
cls, session: Session, playlist_id: int, plr_ids: List[int] cls, session: Session, playlist_id: int, plr_ids: list[int]
) -> Sequence["PlaylistRows"]: ) -> Sequence["PlaylistRows"]:
""" """
Take a list of PlaylistRows ids and return a list of corresponding Take a list of PlaylistRows ids and return a list of corresponding
@ -577,12 +574,10 @@ class PlaylistRows(dbtables.PlaylistRowsTable):
) )
@staticmethod @staticmethod
@line_profiler.profile
def update_plr_row_numbers( def update_plr_row_numbers(
session: Session, session: Session,
playlist_id: int, playlist_id: int,
sqla_map: List[dict[str, int]], sqla_map: list[dict[str, int]],
dummy_for_profiling: Optional[int] = None,
) -> None: ) -> None:
""" """
Take a {plrid: row_number} dictionary and update the row numbers accordingly Take a {plrid: row_number} dictionary and update the row numbers accordingly

View File

@ -154,14 +154,11 @@ class _FadeCurve:
if self.region is None: if self.region is None:
# Create the region now that we're into fade # Create the region now that we're into fade
log.debug("issue223: _FadeCurve: create region")
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) self.GraphWidget.addItem(self.region)
# Update region position # Update region position
if self.region: if self.region:
# Next line is very noisy
# log.debug("issue223: _FadeCurve: update region")
self.region.setRegion([0, ms_of_graph * self.ms_to_array_factor]) self.region.setRegion([0, ms_of_graph * self.ms_to_array_factor])
@ -578,7 +575,6 @@ class RowAndTrack:
def play(self, position: Optional[float] = None) -> None: def play(self, position: Optional[float] = None) -> None:
"""Play track""" """Play track"""
log.debug(f"issue223: RowAndTrack: play {self.track_id=}")
now = dt.datetime.now() now = dt.datetime.now()
self.start_time = now self.start_time = now

View File

@ -2,7 +2,7 @@
# Standard library imports # Standard library imports
from slugify import slugify # type: ignore from slugify import slugify # type: ignore
from typing import List, Optional from typing import Optional
import argparse import argparse
import datetime as dt import datetime as dt
import os import os
@ -44,7 +44,6 @@ from PyQt6.QtWidgets import (
) )
# Third party imports # Third party imports
import line_profiler
from pygame import mixer from pygame import mixer
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
import stackprinter # type: ignore import stackprinter # type: ignore
@ -393,7 +392,7 @@ class Window(QMainWindow, Ui_MainWindow):
self.widgetFadeVolume.setDefaultPadding(0) self.widgetFadeVolume.setDefaultPadding(0)
self.widgetFadeVolume.setBackground(Config.FADE_CURVE_BACKGROUND) self.widgetFadeVolume.setBackground(Config.FADE_CURVE_BACKGROUND)
self.move_source_rows: Optional[List[int]] = None self.move_source_rows: Optional[list[int]] = None
self.move_source_model: Optional[PlaylistModel] = None self.move_source_model: Optional[PlaylistModel] = None
self.disable_selection_timing = False self.disable_selection_timing = False
@ -534,7 +533,7 @@ class Window(QMainWindow, Ui_MainWindow):
if current_track_playlist_id: if current_track_playlist_id:
if closing_tab_playlist_id == current_track_playlist_id: if closing_tab_playlist_id == current_track_playlist_id:
helpers.show_OK( helpers.show_OK(
self, "Current track", "Can't close current track playlist" "Current track", "Can't close current track playlist", self
) )
return False return False
@ -544,7 +543,7 @@ class Window(QMainWindow, Ui_MainWindow):
if next_track_playlist_id: if next_track_playlist_id:
if closing_tab_playlist_id == next_track_playlist_id: if closing_tab_playlist_id == next_track_playlist_id:
helpers.show_OK( helpers.show_OK(
self, "Next track", "Can't close next track playlist" "Next track", "Can't close next track playlist", self
) )
return False return False
@ -571,18 +570,18 @@ class Window(QMainWindow, Ui_MainWindow):
) )
self.actionExport_playlist.triggered.connect(self.export_playlist_tab) self.actionExport_playlist.triggered.connect(self.export_playlist_tab)
self.actionFade.triggered.connect(self.fade) self.actionFade.triggered.connect(self.fade)
self.actionImport_files.triggered.connect(self.import_files_wrapper)
self.actionInsertSectionHeader.triggered.connect(self.insert_header) self.actionInsertSectionHeader.triggered.connect(self.insert_header)
self.actionInsertTrack.triggered.connect(self.insert_track) self.actionInsertTrack.triggered.connect(self.insert_track)
self.actionManage_templates.triggered.connect(self.manage_templates)
self.actionMark_for_moving.triggered.connect(self.mark_rows_for_moving) self.actionMark_for_moving.triggered.connect(self.mark_rows_for_moving)
self.actionMoveSelected.triggered.connect(self.move_selected) self.actionMoveSelected.triggered.connect(self.move_selected)
self.actionMoveUnplayed.triggered.connect(self.move_unplayed) self.actionMoveUnplayed.triggered.connect(self.move_unplayed)
self.actionManage_templates.triggered.connect(self.manage_templates)
self.actionNewPlaylist.triggered.connect(self.new_playlist) self.actionNewPlaylist.triggered.connect(self.new_playlist)
self.actionOpenPlaylist.triggered.connect(self.open_playlist) self.actionOpenPlaylist.triggered.connect(self.open_playlist)
self.actionPaste.triggered.connect(self.paste_rows) self.actionPaste.triggered.connect(self.paste_rows)
self.actionPlay_next.triggered.connect(self.play_next) self.actionPlay_next.triggered.connect(self.play_next)
self.actionRenamePlaylist.triggered.connect(self.rename_playlist) self.actionRenamePlaylist.triggered.connect(self.rename_playlist)
self.actionReplace_files.triggered.connect(self.import_files_wrapper)
self.actionResume.triggered.connect(self.resume) self.actionResume.triggered.connect(self.resume)
self.actionSave_as_template.triggered.connect(self.save_as_template) self.actionSave_as_template.triggered.connect(self.save_as_template)
self.actionSearch_title_in_Songfacts.triggered.connect( self.actionSearch_title_in_Songfacts.triggered.connect(
@ -631,7 +630,9 @@ class Window(QMainWindow, Ui_MainWindow):
self.signals.search_songfacts_signal.connect(self.open_songfacts_browser) self.signals.search_songfacts_signal.connect(self.open_songfacts_browser)
self.signals.search_wikipedia_signal.connect(self.open_wikipedia_browser) self.signals.search_wikipedia_signal.connect(self.open_wikipedia_browser)
def create_playlist(self, session: Session, playlist_name: str) -> Optional[Playlists]: def create_playlist(
self, session: Session, playlist_name: str
) -> Optional[Playlists]:
"""Create new playlist""" """Create new playlist"""
log.debug(f"create_playlist({playlist_name=}") log.debug(f"create_playlist({playlist_name=}")
@ -857,11 +858,8 @@ class Window(QMainWindow, Ui_MainWindow):
# We need to keep a reference to the FileImporter else it will be # We need to keep a reference to the FileImporter else it will be
# garbage collected while import threads are still running # garbage collected while import threads are still running
self.importer = FileImporter( self.importer = FileImporter(self.current.base_model, self.current_row_or_end())
self.current.base_model, self.importer.start()
self.current_row_or_end()
)
self.importer.do_import()
def insert_header(self) -> None: def insert_header(self) -> None:
"""Show dialog box to enter header text and add to playlist""" """Show dialog box to enter header text and add to playlist"""
@ -974,7 +972,9 @@ class Window(QMainWindow, Ui_MainWindow):
playlist.delete(session) playlist.delete(session)
session.commit() session.commit()
else: else:
raise ApplicationError(f"Unrecognised action from EditDeleteDialog: {action=}") raise ApplicationError(
f"Unrecognised action from EditDeleteDialog: {action=}"
)
def mark_rows_for_moving(self) -> None: def mark_rows_for_moving(self) -> None:
""" """
@ -990,7 +990,7 @@ class Window(QMainWindow, Ui_MainWindow):
f"mark_rows_for_moving(): {self.move_source_rows=} {self.move_source_model=}" f"mark_rows_for_moving(): {self.move_source_rows=} {self.move_source_model=}"
) )
def move_playlist_rows(self, row_numbers: List[int]) -> None: def move_playlist_rows(self, row_numbers: list[int]) -> None:
""" """
Move passed playlist rows to another playlist Move passed playlist rows to another playlist
""" """
@ -1140,8 +1140,7 @@ class Window(QMainWindow, Ui_MainWindow):
else: else:
webbrowser.get("browser").open_new_tab(url) webbrowser.get("browser").open_new_tab(url)
@line_profiler.profile def paste_rows(self) -> None:
def paste_rows(self, dummy_for_profiling: Optional[int] = None) -> None:
""" """
Paste earlier cut rows. Paste earlier cut rows.
""" """
@ -1193,8 +1192,6 @@ class Window(QMainWindow, Ui_MainWindow):
- Update headers - Update headers
""" """
log.debug(f"issue223: play_next({position=})")
# If there is no next track set, return. # If there is no next track set, return.
if track_sequence.next is None: if track_sequence.next is None:
log.error("musicmuster.play_next(): no next track selected") log.error("musicmuster.play_next(): no next track selected")
@ -1205,10 +1202,9 @@ class Window(QMainWindow, Ui_MainWindow):
return return
# Issue #223 concerns a very short pause (maybe 0.1s) sometimes # Issue #223 concerns a very short pause (maybe 0.1s) sometimes
# when starting to play at track. # when starting to play at track. Resolution appears to be to
# disable timer10 for a short time. Timer is re-enabled in
# Resolution appears to be to disable timer10 for a short time. # update_clocks.
# Length of time and re-enabling of timer10 both in update_clocks.
self.timer10.stop() self.timer10.stop()
log.debug("issue223: play_next: 10ms timer disabled") log.debug("issue223: play_next: 10ms timer disabled")
@ -1227,38 +1223,29 @@ class Window(QMainWindow, Ui_MainWindow):
# Restore volume if -3dB active # Restore volume if -3dB active
if self.btnDrop3db.isChecked(): if self.btnDrop3db.isChecked():
log.debug("issue223: play_next: Reset -3db button")
self.btnDrop3db.setChecked(False) self.btnDrop3db.setChecked(False)
# Play (new) current track # Play (new) current track
log.info(f"Play: {track_sequence.current.title}") log.debug(f"Play: {track_sequence.current.title}")
track_sequence.current.play(position) track_sequence.current.play(position)
# Update clocks now, don't wait for next tick # Update clocks now, don't wait for next tick
log.debug("issue223: play_next: update_clocks()")
self.update_clocks() self.update_clocks()
# Show closing volume graph # Show closing volume graph
if track_sequence.current.fade_graph: if track_sequence.current.fade_graph:
log.debug(
f"issue223: play_next: set up fade_graph, {track_sequence.current.title=}"
)
track_sequence.current.fade_graph.GraphWidget = self.widgetFadeVolume track_sequence.current.fade_graph.GraphWidget = self.widgetFadeVolume
track_sequence.current.fade_graph.clear() track_sequence.current.fade_graph.clear()
track_sequence.current.fade_graph.plot() track_sequence.current.fade_graph.plot()
else:
log.debug("issue223: play_next: No fade_graph")
# Disable play next controls # Disable play next controls
self.catch_return_key = True self.catch_return_key = True
self.show_status_message("Play controls: Disabled", 0) self.show_status_message("Play controls: Disabled", 0)
# Notify playlist # Notify playlist
log.debug("issue223: play_next: notify playlist")
self.active_tab().current_track_started() self.active_tab().current_track_started()
# Update headers # Update headers
log.debug("issue223: play_next: update headers")
self.update_headers() self.update_headers()
with db.Session() as session: with db.Session() as session:
last_played = Playdates.last_played_tracks(session) last_played = Playdates.last_played_tracks(session)
@ -1478,11 +1465,9 @@ class Window(QMainWindow, Ui_MainWindow):
helpers.show_warning( helpers.show_warning(
self, "Duplicate template", "Template name already in use" self, "Duplicate template", "Template name already in use"
) )
Playlists.save_as_template( Playlists.save_as_template(session, self.current.playlist_id, template_name)
session, self.current.playlist_id, template_name
)
session.commit() session.commit()
helpers.show_OK(self, "Template", "Template saved") helpers.show_OK("Template", "Template saved", self)
def search_playlist(self) -> None: def search_playlist(self) -> None:
"""Show text box to search playlist""" """Show text box to search playlist"""
@ -1731,15 +1716,6 @@ class Window(QMainWindow, Ui_MainWindow):
# If track is playing, update track clocks time and colours # If track is playing, update track clocks time and colours
if track_sequence.current and track_sequence.current.is_playing(): if track_sequence.current and track_sequence.current.is_playing():
# see play_next() and issue #223.
# TODO: find a better way of handling this
if (
track_sequence.current.time_playing() > 5000
and not self.timer10.isActive()
):
self.timer10.start(10)
log.debug("issue223: update_clocks: 10ms timer enabled")
# Elapsed time # Elapsed time
self.label_elapsed_timer.setText( self.label_elapsed_timer.setText(
helpers.ms_to_mmss(track_sequence.current.time_playing()) helpers.ms_to_mmss(track_sequence.current.time_playing())
@ -1767,14 +1743,22 @@ class Window(QMainWindow, Ui_MainWindow):
if self.frame_silent.styleSheet() != css_fade: if self.frame_silent.styleSheet() != css_fade:
self.frame_silent.setStyleSheet(css_fade) self.frame_silent.setStyleSheet(css_fade)
# Five seconds before fade starts, set warning colour on # WARNING_MS_BEFORE_FADE milliseconds before fade starts, set
# time to silence box and enable play controls # warning colour on time to silence box and enable play
# controls. This is also a good time to re-enable the 10ms
# timer (see play_next() and issue #223).
elif time_to_fade <= Config.WARNING_MS_BEFORE_FADE: elif time_to_fade <= Config.WARNING_MS_BEFORE_FADE:
self.frame_fade.setStyleSheet( self.frame_fade.setStyleSheet(
f"background: {Config.COLOUR_WARNING_TIMER}" f"background: {Config.COLOUR_WARNING_TIMER}"
) )
self.catch_return_key = False self.catch_return_key = False
self.show_status_message("Play controls: Enabled", 0) self.show_status_message("Play controls: Enabled", 0)
# Re-enable 10ms timer (see above)
if not self.timer10.isActive():
self.timer10.start(10)
log.debug("issue223: update_clocks: 10ms timer enabled")
else: else:
self.frame_silent.setStyleSheet("") self.frame_silent.setStyleSheet("")
self.frame_fade.setStyleSheet("") self.frame_fade.setStyleSheet("")

View File

@ -26,7 +26,6 @@ from PyQt6.QtGui import (
) )
# Third party imports # Third party imports
import line_profiler
import obswebsocket # type: ignore import obswebsocket # type: ignore
# import snoop # type: ignore # import snoop # type: ignore
@ -297,22 +296,17 @@ class PlaylistModel(QAbstractTableModel):
self.update_track_times() self.update_track_times()
# Find next track # Find next track
# Get all unplayed track rows
log.debug(f"{self}: Find next track")
next_row = None next_row = None
unplayed_rows = self.get_unplayed_rows() unplayed_rows = [
a
for a in self.get_unplayed_rows()
if not self.is_header_row(a)
and not file_is_unreadable(self.playlist_rows[a].path)
]
if unplayed_rows: if unplayed_rows:
try: try:
# Find next row after current track next_row = min([a for a in unplayed_rows if a > row_number])
next_row = min(
[
a
for a in unplayed_rows
if a > row_number and not self.is_header_row(a)
]
)
except ValueError: except ValueError:
# Find first unplayed track
next_row = min(unplayed_rows) next_row = min(unplayed_rows)
if next_row is not None: if next_row is not None:
self.set_next_row(next_row) self.set_next_row(next_row)
@ -777,9 +771,7 @@ class PlaylistModel(QAbstractTableModel):
return None return None
def load_data( def load_data(self, session: db.session) -> None:
self, session: db.session, dummy_for_profiling: Optional[int] = None
) -> None:
""" """
Same as refresh data, but only used when creating playslit. Same as refresh data, but only used when creating playslit.
Distinguishes profile time between initial load and other Distinguishes profile time between initial load and other
@ -826,13 +818,7 @@ class PlaylistModel(QAbstractTableModel):
self.update_track_times() self.update_track_times()
self.invalidate_rows(row_numbers) self.invalidate_rows(row_numbers)
@line_profiler.profile def move_rows(self, from_rows: list[int], to_row_number: int) -> None:
def move_rows(
self,
from_rows: list[int],
to_row_number: int,
dummy_for_profiling: Optional[int] = None,
) -> None:
""" """
Move the playlist rows given to to_row and below. Move the playlist rows given to to_row and below.
""" """
@ -897,13 +883,11 @@ class PlaylistModel(QAbstractTableModel):
self.update_track_times() self.update_track_times()
self.invalidate_rows(list(row_map.keys())) self.invalidate_rows(list(row_map.keys()))
@line_profiler.profile
def move_rows_between_playlists( def move_rows_between_playlists(
self, self,
from_rows: list[int], from_rows: list[int],
to_row_number: int, to_row_number: int,
to_playlist_id: int, to_playlist_id: int,
dummy_for_profiling: Optional[int] = None,
) -> None: ) -> None:
""" """
Move the playlist rows given to to_row and below of to_playlist. Move the playlist rows given to to_row and below of to_playlist.
@ -1046,7 +1030,7 @@ class PlaylistModel(QAbstractTableModel):
log.debug(f"{self}: OBS scene changed to '{scene_name}'") log.debug(f"{self}: OBS scene changed to '{scene_name}'")
continue continue
except obswebsocket.exceptions.ConnectionFailure: except obswebsocket.exceptions.ConnectionFailure:
log.error(f"{self}: OBS connection refused") log.warning(f"{self}: OBS connection refused")
return return
def previous_track_ended(self) -> None: def previous_track_ended(self) -> None:
@ -1076,10 +1060,7 @@ class PlaylistModel(QAbstractTableModel):
# Update display # Update display
self.invalidate_row(track_sequence.previous.row_number) self.invalidate_row(track_sequence.previous.row_number)
@line_profiler.profile def refresh_data(self, session: db.session) -> None:
def refresh_data(
self, session: db.session, dummy_for_profiling: Optional[int] = None
) -> None:
""" """
Populate self.playlist_rows with playlist data Populate self.playlist_rows with playlist data
@ -1170,6 +1151,7 @@ class PlaylistModel(QAbstractTableModel):
]: ]:
if ts: if ts:
ts.update_playlist_and_row(session) ts.update_playlist_and_row(session)
session.commit()
self.update_track_times() self.update_track_times()
@ -1582,6 +1564,23 @@ class PlaylistModel(QAbstractTableModel):
) )
) )
def update_or_insert(self, track_id: int, row_number: int) -> None:
"""
If the passed track_id exists in this playlist, update the
row(s), otherwise insert this track at row_number.
"""
track_rows = [
a.row_number for a in self.playlist_rows.values() if a.track_id == track_id
]
if track_rows:
with db.Session() as session:
for row in track_rows:
self.refresh_row(session, row)
self.invalidate_rows(track_rows)
else:
self.insert_row(proposed_row_number=row_number, track_id=track_id)
def update_track_times(self) -> None: def update_track_times(self) -> None:
""" """
Update track start/end times in self.playlist_rows Update track start/end times in self.playlist_rows

View File

@ -1,5 +1,5 @@
# Standard library imports # Standard library imports
from typing import Any, Callable, cast, List, Optional, TYPE_CHECKING from typing import Any, Callable, cast, Optional, TYPE_CHECKING
# PyQt imports # PyQt imports
from PyQt6.QtCore import ( from PyQt6.QtCore import (
@ -33,7 +33,6 @@ from PyQt6.QtWidgets import (
) )
# Third party imports # Third party imports
import line_profiler
# App imports # App imports
from audacity_controller import AudacityController from audacity_controller import AudacityController
@ -214,10 +213,10 @@ class PlaylistDelegate(QStyledItemDelegate):
doc.setTextWidth(option.rect.width()) doc.setTextWidth(option.rect.width())
doc.setDefaultFont(option.font) doc.setDefaultFont(option.font)
doc.setDocumentMargin(Config.ROW_PADDING) doc.setDocumentMargin(Config.ROW_PADDING)
if '\n' in option.text: if "\n" in option.text:
txt = option.text.replace('\n', '<br>') txt = option.text.replace("\n", "<br>")
elif '\u2028' in option.text: elif "\u2028" in option.text:
txt = option.text.replace('\u2028', '<br>') txt = option.text.replace("\u2028", "<br>")
else: else:
txt = option.text txt = option.text
doc.setHtml(txt) doc.setHtml(txt)
@ -378,10 +377,7 @@ class PlaylistTab(QTableView):
# Deselect edited line # Deselect edited line
self.clear_selection() self.clear_selection()
@line_profiler.profile def dropEvent(self, event: Optional[QDropEvent]) -> None:
def dropEvent(
self, event: Optional[QDropEvent], dummy_for_profiling: Optional[int] = None
) -> None:
""" """
Move dropped rows Move dropped rows
""" """
@ -828,7 +824,7 @@ class PlaylistTab(QTableView):
else: else:
return None return None
def get_selected_rows(self) -> List[int]: def get_selected_rows(self) -> list[int]:
"""Return a list of model-selected row numbers sorted by row""" """Return a list of model-selected row numbers sorted by row"""
# Use a set to deduplicate result (a selected row will have all # Use a set to deduplicate result (a selected row will have all
@ -880,9 +876,9 @@ class PlaylistTab(QTableView):
else: else:
txt = f"Can't find info about row{row_number}" txt = f"Can't find info about row{row_number}"
show_OK(self.musicmuster, "Track info", txt) show_OK("Track info", txt, self.musicmuster)
def _mark_as_unplayed(self, row_numbers: List[int]) -> None: def _mark_as_unplayed(self, row_numbers: list[int]) -> None:
"""Mark row as unplayed""" """Mark row as unplayed"""
self.get_base_model().mark_unplayed(row_numbers) self.get_base_model().mark_unplayed(row_numbers)
@ -1006,7 +1002,7 @@ class PlaylistTab(QTableView):
return None return None
return self.model().mapToSource(selected_index).row() return self.model().mapToSource(selected_index).row()
def selected_model_row_numbers(self) -> List[int]: def selected_model_row_numbers(self) -> list[int]:
""" """
Return a list of model row numbers corresponding to the selected rows or Return a list of model row numbers corresponding to the selected rows or
an empty list. an empty list.
@ -1035,7 +1031,7 @@ class PlaylistTab(QTableView):
return row_indexes[0] return row_indexes[0]
def _selected_row_indexes(self) -> List[QModelIndex]: def _selected_row_indexes(self) -> list[QModelIndex]:
""" """
Return a list of indexes of column 0 of selected rows Return a list of indexes of column 0 of selected rows
""" """

View File

@ -1000,7 +1000,7 @@ padding-left: 8px;</string>
<addaction name="actionSave_as_template"/> <addaction name="actionSave_as_template"/>
<addaction name="actionManage_templates"/> <addaction name="actionManage_templates"/>
<addaction name="separator"/> <addaction name="separator"/>
<addaction name="actionReplace_files"/> <addaction name="actionImport_files"/>
<addaction name="separator"/> <addaction name="separator"/>
<addaction name="actionE_xit"/> <addaction name="actionE_xit"/>
</widget> </widget>
@ -1364,7 +1364,7 @@ padding-left: 8px;</string>
<string>Select duplicate rows...</string> <string>Select duplicate rows...</string>
</property> </property>
</action> </action>
<action name="actionReplace_files"> <action name="actionImport_files">
<property name="text"> <property name="text">
<string>Import files...</string> <string>Import files...</string>
</property> </property>

View File

@ -1,6 +1,6 @@
# Form implementation generated from reading ui file 'app/ui/main_window.ui' # Form implementation generated from reading ui file 'app/ui/main_window.ui'
# #
# Created by: PyQt6 UI code generator 6.7.1 # Created by: PyQt6 UI code generator 6.8.0
# #
# WARNING: Any manual changes made to this file will be lost when pyuic6 is # WARNING: Any manual changes made to this file will be lost when pyuic6 is
# run again. Do not edit this file unless you know what you are doing. # run again. Do not edit this file unless you know what you are doing.
@ -529,8 +529,8 @@ class Ui_MainWindow(object):
self.actionSearch_title_in_Songfacts.setObjectName("actionSearch_title_in_Songfacts") self.actionSearch_title_in_Songfacts.setObjectName("actionSearch_title_in_Songfacts")
self.actionSelect_duplicate_rows = QtGui.QAction(parent=MainWindow) self.actionSelect_duplicate_rows = QtGui.QAction(parent=MainWindow)
self.actionSelect_duplicate_rows.setObjectName("actionSelect_duplicate_rows") self.actionSelect_duplicate_rows.setObjectName("actionSelect_duplicate_rows")
self.actionReplace_files = QtGui.QAction(parent=MainWindow) self.actionImport_files = QtGui.QAction(parent=MainWindow)
self.actionReplace_files.setObjectName("actionReplace_files") self.actionImport_files.setObjectName("actionImport_files")
self.menuFile.addSeparator() self.menuFile.addSeparator()
self.menuFile.addAction(self.actionInsertTrack) self.menuFile.addAction(self.actionInsertTrack)
self.menuFile.addAction(self.actionRemove) self.menuFile.addAction(self.actionRemove)
@ -557,7 +557,7 @@ class Ui_MainWindow(object):
self.menuPlaylist.addAction(self.actionSave_as_template) self.menuPlaylist.addAction(self.actionSave_as_template)
self.menuPlaylist.addAction(self.actionManage_templates) self.menuPlaylist.addAction(self.actionManage_templates)
self.menuPlaylist.addSeparator() self.menuPlaylist.addSeparator()
self.menuPlaylist.addAction(self.actionReplace_files) self.menuPlaylist.addAction(self.actionImport_files)
self.menuPlaylist.addSeparator() self.menuPlaylist.addSeparator()
self.menuPlaylist.addAction(self.actionE_xit) self.menuPlaylist.addAction(self.actionE_xit)
self.menuSearc_h.addAction(self.actionSetNext) self.menuSearc_h.addAction(self.actionSetNext)
@ -676,6 +676,6 @@ class Ui_MainWindow(object):
self.actionSearch_title_in_Songfacts.setText(_translate("MainWindow", "Search title in Songfacts")) self.actionSearch_title_in_Songfacts.setText(_translate("MainWindow", "Search title in Songfacts"))
self.actionSearch_title_in_Songfacts.setShortcut(_translate("MainWindow", "Ctrl+S")) self.actionSearch_title_in_Songfacts.setShortcut(_translate("MainWindow", "Ctrl+S"))
self.actionSelect_duplicate_rows.setText(_translate("MainWindow", "Select duplicate rows...")) self.actionSelect_duplicate_rows.setText(_translate("MainWindow", "Select duplicate rows..."))
self.actionReplace_files.setText(_translate("MainWindow", "Import files...")) self.actionImport_files.setText(_translate("MainWindow", "Import files..."))
from infotabs import InfoTabs # type: ignore from infotabs import InfoTabs
from pyqtgraph import PlotWidget # type: ignore from pyqtgraph import PlotWidget # type: ignore

1213
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,49 +1,52 @@
[tool.poetry] [project]
name = "musicmuster" name = "musicmuster"
version = "1.7.5" version = "4.1.10"
description = "Music player for internet radio" description = "Music player for internet radio"
authors = ["Keith Edmunds <kae@midnighthax.com>"] authors = [
{ name = "Keith Edmunds", email = "kae@midnighthax.com" }
]
readme = "README.md"
requires-python = ">=3.11,<4.0"
dependencies = [
"alchemical>=1.0.2",
"alembic>=1.14.0",
"colorlog>=6.9.0",
"fuzzywuzzy>=0.18.0",
"mutagen>=1.47.0",
"mysqlclient>=2.2.5",
"obs-websocket-py>=1.0",
"psutil>=6.1.0",
"pydub>=0.25.1",
"pydymenu>=0.5.2",
"pyfzf>=0.3.1",
"pygame>=2.6.1",
"pyqt6>=6.7.1",
"pyqt6-webengine>=6.7.0",
"pyqtgraph>=0.13.3",
"python-levenshtein>=0.26.1",
"python-slugify>=8.0.4",
"python-vlc>=3.0.21203",
"SQLAlchemy>=2.0.36",
"stackprinter>=0.2.10",
"tinytag>=1.10.1",
"types-psutil>=6.0.0.20240621",
]
[tool.poetry.dependencies]
python = "^3.11"
tinytag = "^1.10.1"
SQLAlchemy = "^2.0.36"
python-vlc = "^3.0.21203"
mysqlclient = "^2.2.5"
mutagen = "^1.47.0"
alembic = "^1.14.0"
pydub = "^0.25.1"
python-slugify = "^8.0.4"
pyfzf = "^0.3.1"
pydymenu = "^0.5.2"
stackprinter = "^0.2.10"
pyqt6 = "^6.7.1"
pyqtgraph = "^0.13.3"
colorlog = "^6.9.0"
alchemical = "^1.0.2"
obs-websocket-py = "^1.0"
pygame = "^2.6.1"
psutil = "^6.1.0"
pyqt6-webengine = "^6.7.0"
fuzzywuzzy = "^0.18.0"
python-levenshtein = "^0.26.1"
[tool.poetry.dev-dependencies] [tool.poetry]
ipdb = "^0.13.9" package-mode = false
pytest-qt = "^4.4.0"
pydub-stubs = "^0.25.1"
line-profiler = "^4.1.3"
flakehell = "^0.9.0"
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]
pudb = "*"
flakehell = "^0.9.0" flakehell = "^0.9.0"
mypy = "^1.7.0" ipdb = "^0.13.9"
pytest-cov = "^5.0.0" line-profiler = "^4.2.0"
pytest = "^8.1.1" mypy = "^1.15.0"
black = "^24.3.0" pudb = "*"
types-psutil = "^6.0.0.20240621" pydub-stubs = "^0.25.1"
pdbp = "^1.5.3" pytest = "^8.3.4"
pytest-qt = "^4.4.0"
black = "^25.1.0"
pytest-cov = "^6.0.0"
[build-system] [build-system]
requires = ["poetry-core>=1.0.0"] requires = ["poetry-core>=1.0.0"]
@ -65,3 +68,4 @@ filterwarnings = ["ignore:'audioop' is deprecated", "ignore:pkg_resources"]
exclude = ["migrations", "app/ui", "archive"] exclude = ["migrations", "app/ui", "archive"]
paths = ["app"] paths = ["app"]
make_whitelist = true make_whitelist = true

489
tests/test_file_importer.py Normal file
View File

@ -0,0 +1,489 @@
"""
Tests are named 'test_nnn_xxxx' where 'nn n' is a number. This is used to ensure that
the tests run in order as we rely (in some cases) upon the results of an earlier test.
Yes, we shouldn't do that.
"""
# Standard library imports
import os
import shutil
import tempfile
import unittest
from unittest.mock import MagicMock, patch
# PyQt imports
from PyQt6.QtWidgets import QDialog, QFileDialog
# Third party imports
from mutagen.mp3 import MP3 # type: ignore
import pytest
from pytestqt.plugin import QtBot # type: ignore
# App imports
from app import musicmuster
from app.models import (
db,
Playlists,
Tracks,
)
from config import Config
from file_importer import FileImporter
# Custom fixture to adapt qtbot for use with unittest.TestCase
@pytest.fixture(scope="class")
def qtbot_adapter(qapp, request):
"""Adapt qtbot fixture for usefixtures and unittest.TestCase"""
request.cls.qtbot = QtBot(request)
# Fixture for tmp_path to be available in the class
@pytest.fixture(scope="class")
def class_tmp_path(request, tmp_path_factory):
"""Provide a class-wide tmp_path"""
request.cls.tmp_path = tmp_path_factory.mktemp("pytest_tmp")
@pytest.mark.usefixtures("qtbot_adapter", "class_tmp_path")
class MyTestCase(unittest.TestCase):
@classmethod
def setUpClass(cls):
"""Runs once before any test in this class"""
db.create_all()
cls.widget = musicmuster.Window()
# Create a playlist for all tests
playlist_name = "file importer playlist"
with db.Session() as session:
playlist = Playlists(session, playlist_name)
cls.widget.create_playlist_tab(playlist)
# Create our musicstore
cls.import_source = tempfile.mkdtemp(suffix="_MMsource_pytest", dir="/tmp")
Config.REPLACE_FILES_DEFAULT_SOURCE = cls.import_source
cls.musicstore = tempfile.mkdtemp(suffix="_MMstore_pytest", dir="/tmp")
Config.IMPORT_DESTINATION = cls.musicstore
@classmethod
def tearDownClass(cls):
"""Runs once after all tests"""
db.drop_all()
shutil.rmtree(cls.musicstore)
shutil.rmtree(cls.import_source)
def setUp(self):
"""Runs before each test"""
with self.qtbot.waitExposed(self.widget):
self.widget.show()
def tearDown(self):
"""Runs after each test"""
self.widget.close() # Close UI to prevent side effects
def wait_for_workers(self, timeout: int = 10000):
"""
Let import threads workers run to completion
"""
def workers_empty():
assert FileImporter.workers == {}
self.qtbot.waitUntil(workers_empty, timeout=timeout)
def test_001_import_no_files(self):
"""Try importing with no files to import"""
with patch("file_importer.show_OK") as mock_show_ok:
self.widget.import_files_wrapper()
mock_show_ok.assert_called_once_with(
"File import",
f"No files in {Config.REPLACE_FILES_DEFAULT_SOURCE} to import",
None,
)
def test_002_import_file_and_cancel(self):
"""Cancel file import"""
test_track_path = "testdata/isa.mp3"
shutil.copy(test_track_path, self.import_source)
with (
patch("file_importer.PickMatch") as MockPickMatch,
patch("file_importer.show_OK") as mock_show_ok,
):
# Create a mock instance of PickMatch
mock_dialog_instance = MagicMock()
MockPickMatch.return_value = mock_dialog_instance
# Simulate the user clicking OK in the dialog
mock_dialog_instance.exec.return_value = QDialog.DialogCode.Rejected
mock_dialog_instance.selected_track_id = -1 # Simulated return value
self.widget.import_files_wrapper()
# Ensure PickMatch was instantiated correctly
MockPickMatch.assert_called_once_with(
new_track_description="I'm So Afraid (Fleetwood Mac)",
choices=[("Do not import", -1, ""), ("Import as new track", 0, "")],
default=1,
)
# Verify exec() was called
mock_dialog_instance.exec.assert_called_once()
# Ensure selected_track_id was accessed after dialog.exec()
assert mock_dialog_instance.selected_track_id < 0
mock_show_ok.assert_called_once_with(
"File not imported",
"isa.mp3 will not be imported because you asked not to import this file",
)
def test_003_import_first_file(self):
"""Import file into empty directory"""
test_track_path = "testdata/isa.mp3"
shutil.copy(test_track_path, self.import_source)
with patch("file_importer.PickMatch") as MockPickMatch:
# Create a mock instance of PickMatch
mock_dialog_instance = MagicMock()
MockPickMatch.return_value = mock_dialog_instance
# Simulate the user clicking OK in the dialog
mock_dialog_instance.exec.return_value = QDialog.DialogCode.Accepted
mock_dialog_instance.selected_track_id = 0 # Simulated return value
self.widget.import_files_wrapper()
# Ensure PickMatch was instantiated correctly
MockPickMatch.assert_called_once_with(
new_track_description="I'm So Afraid (Fleetwood Mac)",
choices=[("Do not import", -1, ""), ("Import as new track", 0, "")],
default=1,
)
# Verify exec() was called
mock_dialog_instance.exec.assert_called_once()
# Ensure selected_track_id was accessed after dialog.exec()
assert mock_dialog_instance.selected_track_id == 0
self.wait_for_workers()
# Check track was imported
with db.Session() as session:
tracks = Tracks.get_all(session)
assert len(tracks) == 1
track = tracks[0]
assert track.title == "I'm So Afraid"
assert track.artist == "Fleetwood Mac"
track_file = os.path.join(
self.musicstore, os.path.basename(test_track_path)
)
assert track.path == track_file
assert os.path.exists(track_file)
assert os.listdir(self.import_source) == []
def test_004_import_second_file(self):
"""Import a second file"""
test_track_path = "testdata/lovecats.mp3"
shutil.copy(test_track_path, self.import_source)
with patch("file_importer.PickMatch") as MockPickMatch:
# Create a mock instance of PickMatch
mock_dialog_instance = MagicMock()
MockPickMatch.return_value = mock_dialog_instance
# Simulate the user clicking OK in the dialog
mock_dialog_instance.exec.return_value = QDialog.DialogCode.Accepted
mock_dialog_instance.selected_track_id = 0 # Simulated return value
self.widget.import_files_wrapper()
# Ensure PickMatch was instantiated correctly
MockPickMatch.assert_called_once_with(
new_track_description="The Lovecats (The Cure)",
choices=[("Do not import", -1, ""), ("Import as new track", 0, "")],
default=1,
)
# Verify exec() was called
mock_dialog_instance.exec.assert_called_once()
# Ensure selected_track_id was accessed after dialog.exec()
assert mock_dialog_instance.selected_track_id == 0
self.wait_for_workers()
# Check track was imported
with db.Session() as session:
tracks = Tracks.get_all(session)
assert len(tracks) == 2
track = tracks[1]
assert track.title == "The Lovecats"
assert track.artist == "The Cure"
track_file = os.path.join(
self.musicstore, os.path.basename(test_track_path)
)
assert track.path == track_file
assert os.path.exists(track_file)
assert os.listdir(self.import_source) == []
def test_005_replace_file(self):
"""Import the same file again and update existing track"""
test_track_path = "testdata/lovecats.mp3"
shutil.copy(test_track_path, self.import_source)
with patch("file_importer.PickMatch") as MockPickMatch:
# Create a mock instance of PickMatch
mock_dialog_instance = MagicMock()
MockPickMatch.return_value = mock_dialog_instance
# Simulate the user clicking OK in the dialog
mock_dialog_instance.exec.return_value = QDialog.DialogCode.Accepted
mock_dialog_instance.selected_track_id = 2 # Simulated return value
self.widget.import_files_wrapper()
# Ensure PickMatch was instantiated correctly
MockPickMatch.assert_called_once_with(
new_track_description="The Lovecats (The Cure)",
choices=[
("Do not import", -1, ""),
("Import as new track", 0, ""),
(
"The Lovecats (The Cure) (100%)",
2,
os.path.join(
self.musicstore, os.path.basename(test_track_path)
),
),
],
default=2,
)
# Verify exec() was called
mock_dialog_instance.exec.assert_called_once()
self.wait_for_workers()
# Check track was imported
with db.Session() as session:
tracks = Tracks.get_all(session)
assert len(tracks) == 2
track = tracks[1]
assert track.title == "The Lovecats"
assert track.artist == "The Cure"
assert track.id == 2
track_file = os.path.join(
self.musicstore, os.path.basename(test_track_path)
)
assert track.path == track_file
assert os.path.exists(track_file)
assert os.listdir(self.import_source) == []
def test_006_import_file_no_tags(self) -> None:
"""Try to import untagged file"""
test_track_path = "testdata/lovecats.mp3"
test_filename = os.path.basename(test_track_path)
shutil.copy(test_track_path, self.import_source)
import_file = os.path.join(self.import_source, test_filename)
assert os.path.exists(import_file)
# Remove tags
src = MP3(import_file)
src.delete()
src.save()
with patch("file_importer.show_OK") as mock_show_ok:
self.widget.import_files_wrapper()
mock_show_ok.assert_called_once_with(
"File not imported",
f"{test_filename} will not be imported because of tag errors "
f"(Missing tags in {import_file})",
)
def test_007_import_unreadable_file(self) -> None:
"""Import unreadable file"""
test_track_path = "testdata/lovecats.mp3"
test_filename = os.path.basename(test_track_path)
shutil.copy(test_track_path, self.import_source)
import_file = os.path.join(self.import_source, test_filename)
assert os.path.exists(import_file)
# Make undreadable
os.chmod(import_file, 0)
with patch("file_importer.show_OK") as mock_show_ok:
self.widget.import_files_wrapper()
mock_show_ok.assert_called_once_with(
"File not imported",
f"{test_filename} will not be imported because {import_file} is unreadable",
)
# clean up
os.chmod(import_file, 0o777)
os.unlink(import_file)
def test_008_import_new_file_existing_destination(self) -> None:
"""Import duplicate file"""
test_track_path = "testdata/lovecats.mp3"
test_filename = os.path.basename(test_track_path)
new_destination = os.path.join(self.musicstore, "lc2.mp3")
shutil.copy(test_track_path, self.import_source)
import_file = os.path.join(self.import_source, test_filename)
assert os.path.exists(import_file)
with (
patch("file_importer.PickMatch") as MockPickMatch,
patch.object(
QFileDialog, "getSaveFileName", return_value=(new_destination, "")
) as mock_file_dialog,
patch("file_importer.show_OK") as mock_show_ok,
):
mock_file_dialog.return_value = (
new_destination,
"",
) # Ensure mock correctly returns expected value
# Create a mock instance of PickMatch
mock_dialog_instance = MagicMock()
MockPickMatch.return_value = mock_dialog_instance
# Simulate the user clicking OK in the dialog
mock_dialog_instance.exec.return_value = QDialog.DialogCode.Accepted
mock_dialog_instance.selected_track_id = 0 # Simulated return value
self.widget.import_files_wrapper()
# Ensure PickMatch was instantiated correctly
MockPickMatch.assert_called_once_with(
new_track_description="The Lovecats (The Cure)",
choices=[
("Do not import", -1, ""),
("Import as new track", 0, ""),
(
"The Lovecats (The Cure) (100%)",
2,
os.path.join(
self.musicstore, os.path.basename(test_track_path)
),
),
],
default=2,
)
# Verify exec() was called
mock_dialog_instance.exec.assert_called_once()
destination = os.path.join(self.musicstore, test_filename)
mock_show_ok.assert_called_once_with(
title="Desintation path exists",
msg=f"New import requested but default destination path ({destination}) "
"already exists. Click OK and choose where to save this track",
parent=None,
)
self.wait_for_workers()
# Ensure QFileDialog was called and returned expected value
assert mock_file_dialog.called # Ensure the mock was used
result = mock_file_dialog()
assert result[0] == new_destination # Validate return value
# Check track was imported
with db.Session() as session:
tracks = Tracks.get_all(session)
assert len(tracks) == 3
track = tracks[2]
assert track.title == "The Lovecats"
assert track.artist == "The Cure"
assert track.id == 3
assert track.path == new_destination
assert os.path.exists(new_destination)
assert os.listdir(self.import_source) == []
# Remove file so as not to interfere with later tests
session.delete(track)
tracks = Tracks.get_all(session)
assert len(tracks) == 2
session.commit()
os.unlink(new_destination)
assert not os.path.exists(new_destination)
def test_009_import_similar_file(self) -> None:
"""Import file with similar, but different, title"""
test_track_path = "testdata/lovecats.mp3"
test_filename = os.path.basename(test_track_path)
shutil.copy(test_track_path, self.import_source)
import_file = os.path.join(self.import_source, test_filename)
assert os.path.exists(import_file)
# Change title tag
src = MP3(import_file)
src["TIT2"].text[0] += " xyz"
src.save()
with patch("file_importer.PickMatch") as MockPickMatch:
# Create a mock instance of PickMatch
mock_dialog_instance = MagicMock()
MockPickMatch.return_value = mock_dialog_instance
# Simulate the user clicking OK in the dialog
mock_dialog_instance.exec.return_value = QDialog.DialogCode.Accepted
mock_dialog_instance.selected_track_id = 2 # Simulated return value
self.widget.import_files_wrapper()
# Ensure PickMatch was instantiated correctly
MockPickMatch.assert_called_once_with(
new_track_description="The Lovecats xyz (The Cure)",
choices=[
("Do not import", -1, ""),
("Import as new track", 0, ""),
(
"The Lovecats (The Cure) (93%)",
2,
os.path.join(
self.musicstore, os.path.basename(test_track_path)
),
),
],
default=2,
)
# Verify exec() was called
mock_dialog_instance.exec.assert_called_once()
self.wait_for_workers()
# Check track was imported
with db.Session() as session:
tracks = Tracks.get_all(session)
assert len(tracks) == 2
track = tracks[1]
assert track.title == "The Lovecats xyz"
assert track.artist == "The Cure"
assert track.id == 2
track_file = os.path.join(
self.musicstore, os.path.basename(test_track_path)
)
assert track.path == track_file
assert os.path.exists(track_file)
assert os.listdir(self.import_source) == []

View File

@ -3,19 +3,15 @@ import os
import unittest import unittest
# PyQt imports # PyQt imports
from PyQt6.QtCore import Qt
from PyQt6.QtGui import QColor
# Third party imports # Third party imports
import pytest import pytest
from pytestqt.plugin import QtBot # type: ignore from pytestqt.plugin import QtBot # type: ignore
# App imports # App imports
from config import Config
from app import playlistmodel, utilities from app import playlistmodel, utilities
from app.models import ( from app.models import (
db, db,
NoteColours,
Playlists, Playlists,
Tracks, Tracks,
) )