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
from __future__ import annotations
from dataclasses import dataclass, field
from dataclasses import dataclass
from enum import auto, Enum
import functools
import threading
from typing import NamedTuple
# Third party imports
@ -34,11 +35,17 @@ 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 not wrapper_singleton.instance:
if wrapper_singleton.instance is None:
with lock:
if wrapper_singleton.instance is None: # Check still None
wrapper_singleton.instance = cls(*args, **kwargs)
return wrapper_singleton.instance
@ -94,10 +101,10 @@ class MusicMusterSignals(QObject):
class Tags(NamedTuple):
artist: str
title: str
bitrate: int
duration: int
artist: str = ""
title: str = ""
bitrate: int = 0
duration: int = 0
class TrackInfo(NamedTuple):

View File

@ -2,7 +2,6 @@
import datetime as dt
import logging
import os
from typing import List, Optional
# PyQt imports
@ -35,8 +34,6 @@ 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"
@ -51,6 +48,10 @@ 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"
@ -62,8 +63,8 @@ class Config(object):
HEADER_START_TIME = "Start"
HEADER_TITLE = "Title"
HIDE_AFTER_PLAYING_OFFSET = 5000
HIDE_PLAYED_MODE_TRACKS = "TRACKS"
HIDE_PLAYED_MODE_SECTIONS = "SECTIONS"
HIDE_PLAYED_MODE_TRACKS = "TRACKS"
IMPORT_AS_NEW = "Import as new track"
INFO_TAB_TITLE_LENGTH = 15
INTRO_SECONDS_FORMAT = ".1f"
@ -79,10 +80,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 List, Optional
from typing import 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=False)
substring: Mapped[str] = mapped_column(String(256), index=True)
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,13 +146,16 @@ 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"
playlistrows: Mapped[list[PlaylistRowsTable]] = relationship(
"PlaylistRowsTable",
back_populates="track",
cascade="all, delete-orphan",
)
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,6 +1,5 @@
# Standard library imports
from typing import Optional
import os
# PyQt imports
from PyQt6.QtCore import QEvent, Qt
@ -9,27 +8,22 @@ 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 db, Settings, Tracks
from models import Settings, Tracks
from playlistmodel import PlaylistModel
from ui import dlg_TrackSelect_ui, dlg_replace_files_ui
from ui import dlg_TrackSelect_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
from PyQt6.QtWidgets import QMainWindow, QMessageBox, QWidget
# Third party imports
from mutagen.flac import FLAC # type: ignore
@ -200,9 +200,17 @@ def get_tags(path: str) -> Tags:
try:
tag = TinyTag.get(path)
except FileNotFoundError:
raise ApplicationError(f"File not found: get_tags({path=})")
raise ApplicationError(f"File not found: {path}")
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(
title=tag.title,
@ -392,10 +400,16 @@ def set_track_metadata(track: Tracks) -> None:
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"""
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:

View File

@ -1,56 +1,79 @@
#!/usr/bin/python3
#!/usr/bin/env 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 abuse filtering to add an
# extra field to the LogRecord
# We never actually filter messages out, just 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):

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
from __future__ import annotations
from typing import List, Optional, Sequence
from typing import Optional, Sequence
import datetime as dt
import os
import re
@ -10,7 +10,6 @@ import sys
# PyQt imports
# Third party imports
import line_profiler
from sqlalchemy import (
bindparam,
delete,
@ -188,7 +187,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
"""
@ -246,9 +245,7 @@ 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
@ -428,7 +425,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
@ -577,12 +574,10 @@ class PlaylistRows(dbtables.PlaylistRowsTable):
)
@staticmethod
@line_profiler.profile
def update_plr_row_numbers(
session: Session,
playlist_id: int,
sqla_map: List[dict[str, int]],
dummy_for_profiling: Optional[int] = None,
sqla_map: list[dict[str, int]],
) -> None:
"""
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:
# 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])
@ -578,7 +575,6 @@ 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 List, Optional
from typing import Optional
import argparse
import datetime as dt
import os
@ -44,7 +44,6 @@ from PyQt6.QtWidgets import (
)
# Third party imports
import line_profiler
from pygame import mixer
from sqlalchemy.orm.session import Session
import stackprinter # type: ignore
@ -393,7 +392,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
@ -534,7 +533,7 @@ class Window(QMainWindow, Ui_MainWindow):
if current_track_playlist_id:
if closing_tab_playlist_id == current_track_playlist_id:
helpers.show_OK(
self, "Current track", "Can't close current track playlist"
"Current track", "Can't close current track playlist", self
)
return False
@ -544,7 +543,7 @@ class Window(QMainWindow, Ui_MainWindow):
if next_track_playlist_id:
if closing_tab_playlist_id == next_track_playlist_id:
helpers.show_OK(
self, "Next track", "Can't close next track playlist"
"Next track", "Can't close next track playlist", self
)
return False
@ -571,18 +570,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(
@ -631,7 +630,9 @@ 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=}")
@ -857,11 +858,8 @@ 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.do_import()
self.importer = FileImporter(self.current.base_model, self.current_row_or_end())
self.importer.start()
def insert_header(self) -> None:
"""Show dialog box to enter header text and add to playlist"""
@ -974,7 +972,9 @@ 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,8 +1140,7 @@ class Window(QMainWindow, Ui_MainWindow):
else:
webbrowser.get("browser").open_new_tab(url)
@line_profiler.profile
def paste_rows(self, dummy_for_profiling: Optional[int] = None) -> None:
def paste_rows(self) -> None:
"""
Paste earlier cut rows.
"""
@ -1193,8 +1192,6 @@ 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")
@ -1205,10 +1202,9 @@ 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.
# Length of time and re-enabling of timer10 both in update_clocks.
# when starting to play at track. Resolution appears to be to
# disable timer10 for a short time. Timer is re-enabled in
# update_clocks.
self.timer10.stop()
log.debug("issue223: play_next: 10ms timer disabled")
@ -1227,38 +1223,29 @@ 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.info(f"Play: {track_sequence.current.title}")
log.debug(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)
@ -1478,11 +1465,9 @@ 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(self, "Template", "Template saved")
helpers.show_OK("Template", "Template saved", self)
def search_playlist(self) -> None:
"""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_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())
@ -1767,14 +1743,22 @@ class Window(QMainWindow, Ui_MainWindow):
if self.frame_silent.styleSheet() != css_fade:
self.frame_silent.setStyleSheet(css_fade)
# Five seconds before fade starts, set warning colour on
# time to silence box and enable play controls
# 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).
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,7 +26,6 @@ from PyQt6.QtGui import (
)
# Third party imports
import line_profiler
import obswebsocket # type: ignore
# import snoop # type: ignore
@ -297,22 +296,17 @@ 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 = 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:
try:
# 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)
]
)
next_row = min([a for a in unplayed_rows if a > row_number])
except ValueError:
# Find first unplayed track
next_row = min(unplayed_rows)
if next_row is not None:
self.set_next_row(next_row)
@ -777,9 +771,7 @@ class PlaylistModel(QAbstractTableModel):
return None
def load_data(
self, session: db.session, dummy_for_profiling: Optional[int] = None
) -> None:
def load_data(self, session: db.session) -> None:
"""
Same as refresh data, but only used when creating playslit.
Distinguishes profile time between initial load and other
@ -826,13 +818,7 @@ class PlaylistModel(QAbstractTableModel):
self.update_track_times()
self.invalidate_rows(row_numbers)
@line_profiler.profile
def move_rows(
self,
from_rows: list[int],
to_row_number: int,
dummy_for_profiling: Optional[int] = None,
) -> None:
def move_rows(self, from_rows: list[int], to_row_number: int) -> None:
"""
Move the playlist rows given to to_row and below.
"""
@ -897,13 +883,11 @@ 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.
@ -1046,7 +1030,7 @@ class PlaylistModel(QAbstractTableModel):
log.debug(f"{self}: OBS scene changed to '{scene_name}'")
continue
except obswebsocket.exceptions.ConnectionFailure:
log.error(f"{self}: OBS connection refused")
log.warning(f"{self}: OBS connection refused")
return
def previous_track_ended(self) -> None:
@ -1076,10 +1060,7 @@ class PlaylistModel(QAbstractTableModel):
# Update display
self.invalidate_row(track_sequence.previous.row_number)
@line_profiler.profile
def refresh_data(
self, session: db.session, dummy_for_profiling: Optional[int] = None
) -> None:
def refresh_data(self, session: db.session) -> None:
"""
Populate self.playlist_rows with playlist data
@ -1170,6 +1151,7 @@ class PlaylistModel(QAbstractTableModel):
]:
if ts:
ts.update_playlist_and_row(session)
session.commit()
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:
"""
Update track start/end times in self.playlist_rows

View File

@ -1,5 +1,5 @@
# Standard library imports
from typing import Any, Callable, cast, List, Optional, TYPE_CHECKING
from typing import Any, Callable, cast, Optional, TYPE_CHECKING
# PyQt imports
from PyQt6.QtCore import (
@ -33,7 +33,6 @@ from PyQt6.QtWidgets import (
)
# Third party imports
import line_profiler
# App imports
from audacity_controller import AudacityController
@ -214,10 +213,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)
@ -378,10 +377,7 @@ class PlaylistTab(QTableView):
# Deselect edited line
self.clear_selection()
@line_profiler.profile
def dropEvent(
self, event: Optional[QDropEvent], dummy_for_profiling: Optional[int] = None
) -> None:
def dropEvent(self, event: Optional[QDropEvent]) -> None:
"""
Move dropped rows
"""
@ -828,7 +824,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
@ -880,9 +876,9 @@ class PlaylistTab(QTableView):
else:
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"""
self.get_base_model().mark_unplayed(row_numbers)
@ -1006,7 +1002,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.
@ -1035,7 +1031,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="actionReplace_files"/>
<addaction name="actionImport_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="actionReplace_files">
<action name="actionImport_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.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
# 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.actionReplace_files = QtGui.QAction(parent=MainWindow)
self.actionReplace_files.setObjectName("actionReplace_files")
self.actionImport_files = QtGui.QAction(parent=MainWindow)
self.actionImport_files.setObjectName("actionImport_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.actionReplace_files)
self.menuPlaylist.addAction(self.actionImport_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.actionReplace_files.setText(_translate("MainWindow", "Import files..."))
from infotabs import InfoTabs # type: ignore
self.actionImport_files.setText(_translate("MainWindow", "Import files..."))
from infotabs import InfoTabs
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"
version = "1.7.5"
version = "4.1.10"
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]
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]
package-mode = false
[tool.poetry.group.dev.dependencies]
pudb = "*"
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"
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"
[build-system]
requires = ["poetry-core>=1.0.0"]
@ -65,3 +68,4 @@ filterwarnings = ["ignore:'audioop' is deprecated", "ignore:pkg_resources"]
exclude = ["migrations", "app/ui", "archive"]
paths = ["app"]
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
# 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,
)