Compare commits

..

No commits in common. "256de377cf5448f930dbca9273cd69286d41cf3b" and "85cfebe0f71af3609372cccb55551ccd2b7c3bac" have entirely different histories.

20 changed files with 1197 additions and 2002 deletions

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -1,79 +1,56 @@
#!/usr/bin/env python3
#!/usr/bin/python3
# Standard library imports
from collections import defaultdict
import logging
import logging.config
import logging.handlers
import os
import sys
from traceback import print_exception
import yaml
# PyQt imports
# Third party imports
import colorlog
import stackprinter # type: ignore
# App imports
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):
"""Add leveltag"""
def filter(self, record: logging.LogRecord) -> bool:
# Extract the first character of the level name
record.leveltag = record.levelname[0]
# We never actually filter messages out, just add an extra field
# to the LogRecord
# We never actually filter messages out, just abuse filtering to add an
# extra field to the LogRecord
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.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):

View File

@ -1,52 +0,0 @@
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

View File

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

View File

@ -154,11 +154,14 @@ class _FadeCurve:
if self.region is None:
# 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.GraphWidget.addItem(self.region)
# Update region position
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])
@ -575,6 +578,7 @@ class RowAndTrack:
def play(self, position: Optional[float] = None) -> None:
"""Play track"""
log.debug(f"issue223: RowAndTrack: play {self.track_id=}")
now = dt.datetime.now()
self.start_time = now

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
# Form implementation generated from reading ui file 'app/ui/main_window.ui'
#
# Created by: PyQt6 UI code generator 6.8.0
# Created by: PyQt6 UI code generator 6.7.1
#
# 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.
@ -529,8 +529,8 @@ class Ui_MainWindow(object):
self.actionSearch_title_in_Songfacts.setObjectName("actionSearch_title_in_Songfacts")
self.actionSelect_duplicate_rows = QtGui.QAction(parent=MainWindow)
self.actionSelect_duplicate_rows.setObjectName("actionSelect_duplicate_rows")
self.actionImport_files = QtGui.QAction(parent=MainWindow)
self.actionImport_files.setObjectName("actionImport_files")
self.actionReplace_files = QtGui.QAction(parent=MainWindow)
self.actionReplace_files.setObjectName("actionReplace_files")
self.menuFile.addSeparator()
self.menuFile.addAction(self.actionInsertTrack)
self.menuFile.addAction(self.actionRemove)
@ -557,7 +557,7 @@ class Ui_MainWindow(object):
self.menuPlaylist.addAction(self.actionSave_as_template)
self.menuPlaylist.addAction(self.actionManage_templates)
self.menuPlaylist.addSeparator()
self.menuPlaylist.addAction(self.actionImport_files)
self.menuPlaylist.addAction(self.actionReplace_files)
self.menuPlaylist.addSeparator()
self.menuPlaylist.addAction(self.actionE_xit)
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.setShortcut(_translate("MainWindow", "Ctrl+S"))
self.actionSelect_duplicate_rows.setText(_translate("MainWindow", "Select duplicate rows..."))
self.actionImport_files.setText(_translate("MainWindow", "Import files..."))
from infotabs import InfoTabs
self.actionReplace_files.setText(_translate("MainWindow", "Import files..."))
from infotabs import InfoTabs # 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,52 +1,49 @@
[project]
name = "musicmuster"
version = "4.1.10"
description = "Music player for internet radio"
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]
package-mode = false
name = "musicmuster"
version = "1.7.5"
description = "Music player for internet radio"
authors = ["Keith Edmunds <kae@midnighthax.com>"]
[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]
ipdb = "^0.13.9"
pytest-qt = "^4.4.0"
pydub-stubs = "^0.25.1"
line-profiler = "^4.1.3"
flakehell = "^0.9.0"
[tool.poetry.group.dev.dependencies]
flakehell = "^0.9.0"
ipdb = "^0.13.9"
line-profiler = "^4.2.0"
mypy = "^1.15.0"
pudb = "*"
pydub-stubs = "^0.25.1"
pytest = "^8.3.4"
pytest-qt = "^4.4.0"
black = "^25.1.0"
pytest-cov = "^6.0.0"
flakehell = "^0.9.0"
mypy = "^1.7.0"
pytest-cov = "^5.0.0"
pytest = "^8.1.1"
black = "^24.3.0"
types-psutil = "^6.0.0.20240621"
pdbp = "^1.5.3"
[build-system]
requires = ["poetry-core>=1.0.0"]
@ -68,4 +65,3 @@ filterwarnings = ["ignore:'audioop' is deprecated", "ignore:pkg_resources"]
exclude = ["migrations", "app/ui", "archive"]
paths = ["app"]
make_whitelist = true

View File

@ -1,489 +0,0 @@
"""
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,15 +3,19 @@ import os
import unittest
# PyQt imports
from PyQt6.QtCore import Qt
from PyQt6.QtGui import QColor
# Third party imports
import pytest
from pytestqt.plugin import QtBot # type: ignore
# App imports
from config import Config
from app import playlistmodel, utilities
from app.models import (
db,
NoteColours,
Playlists,
Tracks,
)