Compare commits
30 Commits
85cfebe0f7
...
256de377cf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
256de377cf | ||
|
|
a3c405912a | ||
|
|
4e73ea6e6a | ||
|
|
c9b45848dd | ||
|
|
fd0d8b15f7 | ||
|
|
7d0e1c809f | ||
|
|
5cae8e4b19 | ||
|
|
8177e03387 | ||
|
|
f4923314d8 | ||
|
|
24787578bc | ||
|
|
1f4e7cb054 | ||
|
|
92e1a1cac8 | ||
|
|
52a773176c | ||
|
|
cedc7180d4 | ||
|
|
728ac0f8dc | ||
|
|
4741c1d33f | ||
|
|
aa52f33d58 | ||
|
|
2f18ef5f44 | ||
|
|
4927f237ab | ||
|
|
d3a709642b | ||
|
|
3afcfd5856 | ||
|
|
342c0a2285 | ||
|
|
8161fb00b3 | ||
|
|
f9943dc1c4 | ||
|
|
b2000169b3 | ||
|
|
5e72f17793 | ||
|
|
4a4058d211 | ||
|
|
3b71041b66 | ||
|
|
d30bf49c88 | ||
|
|
3a3b1b712d |
@ -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):
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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",
|
||||
)
|
||||
|
||||
|
||||
@ -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
@ -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:
|
||||
|
||||
77
app/log.py
77
app/log.py
@ -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
52
app/logging.yaml
Normal 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
29
app/logging_tester.py
Executable 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()
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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("")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
"""
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
1213
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@ -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
489
tests/test_file_importer.py
Normal 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) == []
|
||||
@ -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,
|
||||
)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user