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
|
# Standard library imports
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass
|
||||||
from enum import auto, Enum
|
from enum import auto, Enum
|
||||||
import functools
|
import functools
|
||||||
|
import threading
|
||||||
from typing import NamedTuple
|
from typing import NamedTuple
|
||||||
|
|
||||||
# Third party imports
|
# Third party imports
|
||||||
@ -34,12 +35,18 @@ def singleton(cls):
|
|||||||
"""
|
"""
|
||||||
Make a class a Singleton class (see
|
Make a class a Singleton class (see
|
||||||
https://realpython.com/primer-on-python-decorators/#creating-singletons)
|
https://realpython.com/primer-on-python-decorators/#creating-singletons)
|
||||||
|
|
||||||
|
Added locking.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
lock = threading.Lock()
|
||||||
|
|
||||||
@functools.wraps(cls)
|
@functools.wraps(cls)
|
||||||
def wrapper_singleton(*args, **kwargs):
|
def wrapper_singleton(*args, **kwargs):
|
||||||
if not wrapper_singleton.instance:
|
if wrapper_singleton.instance is None:
|
||||||
wrapper_singleton.instance = cls(*args, **kwargs)
|
with lock:
|
||||||
|
if wrapper_singleton.instance is None: # Check still None
|
||||||
|
wrapper_singleton.instance = cls(*args, **kwargs)
|
||||||
return wrapper_singleton.instance
|
return wrapper_singleton.instance
|
||||||
|
|
||||||
wrapper_singleton.instance = None
|
wrapper_singleton.instance = None
|
||||||
@ -94,10 +101,10 @@ class MusicMusterSignals(QObject):
|
|||||||
|
|
||||||
|
|
||||||
class Tags(NamedTuple):
|
class Tags(NamedTuple):
|
||||||
artist: str
|
artist: str = ""
|
||||||
title: str
|
title: str = ""
|
||||||
bitrate: int
|
bitrate: int = 0
|
||||||
duration: int
|
duration: int = 0
|
||||||
|
|
||||||
|
|
||||||
class TrackInfo(NamedTuple):
|
class TrackInfo(NamedTuple):
|
||||||
|
|||||||
@ -2,7 +2,6 @@
|
|||||||
import datetime as dt
|
import datetime as dt
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from typing import List, Optional
|
|
||||||
|
|
||||||
# PyQt imports
|
# PyQt imports
|
||||||
|
|
||||||
@ -35,8 +34,6 @@ class Config(object):
|
|||||||
COLOUR_UNREADABLE = "#dc3545"
|
COLOUR_UNREADABLE = "#dc3545"
|
||||||
COLOUR_WARNING_TIMER = "#ffc107"
|
COLOUR_WARNING_TIMER = "#ffc107"
|
||||||
DBFS_SILENCE = -50
|
DBFS_SILENCE = -50
|
||||||
DEBUG_FUNCTIONS: List[Optional[str]] = []
|
|
||||||
DEBUG_MODULES: List[Optional[str]] = []
|
|
||||||
DEFAULT_COLUMN_WIDTH = 200
|
DEFAULT_COLUMN_WIDTH = 200
|
||||||
DISPLAY_SQL = False
|
DISPLAY_SQL = False
|
||||||
DO_NOT_IMPORT = "Do not import"
|
DO_NOT_IMPORT = "Do not import"
|
||||||
@ -51,6 +48,10 @@ class Config(object):
|
|||||||
FADEOUT_DB = -10
|
FADEOUT_DB = -10
|
||||||
FADEOUT_SECONDS = 5
|
FADEOUT_SECONDS = 5
|
||||||
FADEOUT_STEPS_PER_SECOND = 5
|
FADEOUT_STEPS_PER_SECOND = 5
|
||||||
|
FUZZYMATCH_MINIMUM_LIST = 60.0
|
||||||
|
FUZZYMATCH_MINIMUM_SELECT_ARTIST = 80.0
|
||||||
|
FUZZYMATCH_MINIMUM_SELECT_TITLE = 80.0
|
||||||
|
FUZZYMATCH_SHOW_SCORES = True
|
||||||
HEADER_ARTIST = "Artist"
|
HEADER_ARTIST = "Artist"
|
||||||
HEADER_BITRATE = "bps"
|
HEADER_BITRATE = "bps"
|
||||||
HEADER_DURATION = "Length"
|
HEADER_DURATION = "Length"
|
||||||
@ -62,8 +63,8 @@ class Config(object):
|
|||||||
HEADER_START_TIME = "Start"
|
HEADER_START_TIME = "Start"
|
||||||
HEADER_TITLE = "Title"
|
HEADER_TITLE = "Title"
|
||||||
HIDE_AFTER_PLAYING_OFFSET = 5000
|
HIDE_AFTER_PLAYING_OFFSET = 5000
|
||||||
HIDE_PLAYED_MODE_TRACKS = "TRACKS"
|
|
||||||
HIDE_PLAYED_MODE_SECTIONS = "SECTIONS"
|
HIDE_PLAYED_MODE_SECTIONS = "SECTIONS"
|
||||||
|
HIDE_PLAYED_MODE_TRACKS = "TRACKS"
|
||||||
IMPORT_AS_NEW = "Import as new track"
|
IMPORT_AS_NEW = "Import as new track"
|
||||||
INFO_TAB_TITLE_LENGTH = 15
|
INFO_TAB_TITLE_LENGTH = 15
|
||||||
INTRO_SECONDS_FORMAT = ".1f"
|
INTRO_SECONDS_FORMAT = ".1f"
|
||||||
@ -79,10 +80,10 @@ class Config(object):
|
|||||||
MAIL_USERNAME = os.environ.get("MAIL_USERNAME")
|
MAIL_USERNAME = os.environ.get("MAIL_USERNAME")
|
||||||
MAIL_USE_TLS = os.environ.get("MAIL_USE_TLS") is not None
|
MAIL_USE_TLS = os.environ.get("MAIL_USE_TLS") is not None
|
||||||
MAX_IMPORT_MATCHES = 5
|
MAX_IMPORT_MATCHES = 5
|
||||||
|
MAX_IMPORT_THREADS = 3
|
||||||
MAX_INFO_TABS = 5
|
MAX_INFO_TABS = 5
|
||||||
MAX_MISSING_FILES_TO_REPORT = 10
|
MAX_MISSING_FILES_TO_REPORT = 10
|
||||||
MILLISECOND_SIGFIGS = 0
|
MILLISECOND_SIGFIGS = 0
|
||||||
MINIMUM_FUZZYMATCH = 60.0
|
|
||||||
MINIMUM_ROW_HEIGHT = 30
|
MINIMUM_ROW_HEIGHT = 30
|
||||||
NO_TEMPLATE_NAME = "None"
|
NO_TEMPLATE_NAME = "None"
|
||||||
NOTE_TIME_FORMAT = "%H:%M"
|
NOTE_TIME_FORMAT = "%H:%M"
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
# Standard library imports
|
# Standard library imports
|
||||||
from typing import List, Optional
|
from typing import Optional
|
||||||
import datetime as dt
|
import datetime as dt
|
||||||
|
|
||||||
# PyQt imports
|
# PyQt imports
|
||||||
@ -27,7 +27,7 @@ class NoteColoursTable(Model):
|
|||||||
__tablename__ = "notecolours"
|
__tablename__ = "notecolours"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
substring: Mapped[str] = mapped_column(String(256), index=False)
|
substring: Mapped[str] = mapped_column(String(256), index=True)
|
||||||
colour: Mapped[str] = mapped_column(String(21), index=False)
|
colour: Mapped[str] = mapped_column(String(21), index=False)
|
||||||
enabled: Mapped[bool] = mapped_column(default=True, index=True)
|
enabled: Mapped[bool] = mapped_column(default=True, index=True)
|
||||||
foreground: Mapped[Optional[str]] = mapped_column(String(21), index=False)
|
foreground: Mapped[Optional[str]] = mapped_column(String(21), index=False)
|
||||||
@ -73,7 +73,7 @@ class PlaylistsTable(Model):
|
|||||||
tab: Mapped[Optional[int]] = mapped_column(default=None)
|
tab: Mapped[Optional[int]] = mapped_column(default=None)
|
||||||
open: Mapped[bool] = mapped_column(default=False)
|
open: Mapped[bool] = mapped_column(default=False)
|
||||||
is_template: Mapped[bool] = mapped_column(default=False)
|
is_template: Mapped[bool] = mapped_column(default=False)
|
||||||
rows: Mapped[List["PlaylistRowsTable"]] = relationship(
|
rows: Mapped[list["PlaylistRowsTable"]] = relationship(
|
||||||
"PlaylistRowsTable",
|
"PlaylistRowsTable",
|
||||||
back_populates="playlist",
|
back_populates="playlist",
|
||||||
cascade="all, delete-orphan",
|
cascade="all, delete-orphan",
|
||||||
@ -146,13 +146,16 @@ class TracksTable(Model):
|
|||||||
start_gap: Mapped[int] = mapped_column(index=False)
|
start_gap: Mapped[int] = mapped_column(index=False)
|
||||||
title: Mapped[str] = mapped_column(String(256), index=True)
|
title: Mapped[str] = mapped_column(String(256), index=True)
|
||||||
|
|
||||||
playlistrows: Mapped[List[PlaylistRowsTable]] = relationship(
|
playlistrows: Mapped[list[PlaylistRowsTable]] = relationship(
|
||||||
"PlaylistRowsTable", back_populates="track"
|
"PlaylistRowsTable",
|
||||||
|
back_populates="track",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
)
|
)
|
||||||
playlists = association_proxy("playlistrows", "playlist")
|
playlists = association_proxy("playlistrows", "playlist")
|
||||||
playdates: Mapped[List[PlaydatesTable]] = relationship(
|
playdates: Mapped[list[PlaydatesTable]] = relationship(
|
||||||
"PlaydatesTable",
|
"PlaydatesTable",
|
||||||
back_populates="track",
|
back_populates="track",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
lazy="joined",
|
lazy="joined",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
# Standard library imports
|
# Standard library imports
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
import os
|
|
||||||
|
|
||||||
# PyQt imports
|
# PyQt imports
|
||||||
from PyQt6.QtCore import QEvent, Qt
|
from PyQt6.QtCore import QEvent, Qt
|
||||||
@ -9,27 +8,22 @@ from PyQt6.QtWidgets import (
|
|||||||
QDialog,
|
QDialog,
|
||||||
QListWidgetItem,
|
QListWidgetItem,
|
||||||
QMainWindow,
|
QMainWindow,
|
||||||
QTableWidgetItem,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Third party imports
|
# Third party imports
|
||||||
import pydymenu # type: ignore
|
|
||||||
from sqlalchemy.orm.session import Session
|
from sqlalchemy.orm.session import Session
|
||||||
|
|
||||||
# App imports
|
# App imports
|
||||||
from classes import MusicMusterSignals
|
from classes import MusicMusterSignals
|
||||||
from config import Config
|
|
||||||
from helpers import (
|
from helpers import (
|
||||||
ask_yes_no,
|
ask_yes_no,
|
||||||
get_relative_date,
|
get_relative_date,
|
||||||
get_tags,
|
|
||||||
ms_to_mmss,
|
ms_to_mmss,
|
||||||
show_warning,
|
|
||||||
)
|
)
|
||||||
from log import log
|
from log import log
|
||||||
from models import db, Settings, Tracks
|
from models import Settings, Tracks
|
||||||
from playlistmodel import PlaylistModel
|
from playlistmodel import PlaylistModel
|
||||||
from ui import dlg_TrackSelect_ui, dlg_replace_files_ui
|
from ui import dlg_TrackSelect_ui
|
||||||
|
|
||||||
|
|
||||||
class TrackSelectDialog(QDialog):
|
class TrackSelectDialog(QDialog):
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -10,7 +10,7 @@ import ssl
|
|||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
# PyQt imports
|
# PyQt imports
|
||||||
from PyQt6.QtWidgets import QMainWindow, QMessageBox
|
from PyQt6.QtWidgets import QMainWindow, QMessageBox, QWidget
|
||||||
|
|
||||||
# Third party imports
|
# Third party imports
|
||||||
from mutagen.flac import FLAC # type: ignore
|
from mutagen.flac import FLAC # type: ignore
|
||||||
@ -200,9 +200,17 @@ def get_tags(path: str) -> Tags:
|
|||||||
try:
|
try:
|
||||||
tag = TinyTag.get(path)
|
tag = TinyTag.get(path)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
raise ApplicationError(f"File not found: get_tags({path=})")
|
raise ApplicationError(f"File not found: {path}")
|
||||||
except TinyTagException:
|
except TinyTagException:
|
||||||
raise ApplicationError(f"Can't read tags: get_tags({path=})")
|
raise ApplicationError(f"Can't read tags in {path}")
|
||||||
|
|
||||||
|
if (
|
||||||
|
tag.title is None
|
||||||
|
or tag.artist is None
|
||||||
|
or tag.bitrate is None
|
||||||
|
or tag.duration is None
|
||||||
|
):
|
||||||
|
raise ApplicationError(f"Missing tags in {path}")
|
||||||
|
|
||||||
return Tags(
|
return Tags(
|
||||||
title=tag.title,
|
title=tag.title,
|
||||||
@ -392,10 +400,16 @@ def set_track_metadata(track: Tracks) -> None:
|
|||||||
setattr(track, tag_key, getattr(tags, tag_key))
|
setattr(track, tag_key, getattr(tags, tag_key))
|
||||||
|
|
||||||
|
|
||||||
def show_OK(parent: QMainWindow, title: str, msg: str) -> None:
|
def show_OK(title: str, msg: str, parent: Optional[QWidget] = None) -> None:
|
||||||
"""Display a message to user"""
|
"""Display a message to user"""
|
||||||
|
|
||||||
QMessageBox.information(parent, title, msg, buttons=QMessageBox.StandardButton.Ok)
|
dlg = QMessageBox(parent)
|
||||||
|
dlg.setIcon(QMessageBox.Icon.Information)
|
||||||
|
dlg.setWindowTitle(title)
|
||||||
|
dlg.setText(msg)
|
||||||
|
dlg.setStandardButtons(QMessageBox.StandardButton.Ok)
|
||||||
|
|
||||||
|
_ = dlg.exec()
|
||||||
|
|
||||||
|
|
||||||
def show_warning(parent: Optional[QMainWindow], title: str, msg: str) -> None:
|
def show_warning(parent: Optional[QMainWindow], title: str, msg: str) -> None:
|
||||||
|
|||||||
77
app/log.py
77
app/log.py
@ -1,56 +1,79 @@
|
|||||||
#!/usr/bin/python3
|
#!/usr/bin/env python3
|
||||||
# Standard library imports
|
# Standard library imports
|
||||||
|
from collections import defaultdict
|
||||||
import logging
|
import logging
|
||||||
|
import logging.config
|
||||||
import logging.handlers
|
import logging.handlers
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
from traceback import print_exception
|
from traceback import print_exception
|
||||||
|
import yaml
|
||||||
|
|
||||||
# PyQt imports
|
# PyQt imports
|
||||||
|
|
||||||
# Third party imports
|
# Third party imports
|
||||||
import colorlog
|
|
||||||
import stackprinter # type: ignore
|
import stackprinter # type: ignore
|
||||||
|
|
||||||
# App imports
|
# App imports
|
||||||
from config import Config
|
from config import Config
|
||||||
|
|
||||||
|
|
||||||
|
class FunctionFilter(logging.Filter):
|
||||||
|
"""Filter to allow category-based logging to stderr."""
|
||||||
|
|
||||||
|
def __init__(self, module_functions: dict[str, list[str]]):
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
self.modules: list[str] = []
|
||||||
|
self.functions: defaultdict[str, list[str]] = defaultdict(list)
|
||||||
|
|
||||||
|
if module_functions:
|
||||||
|
for module in module_functions.keys():
|
||||||
|
if module_functions[module]:
|
||||||
|
for function in module_functions[module]:
|
||||||
|
self.functions[module].append(function)
|
||||||
|
else:
|
||||||
|
self.modules.append(module)
|
||||||
|
|
||||||
|
def filter(self, record: logging.LogRecord) -> bool:
|
||||||
|
if not getattr(record, "levelname", None) == "DEBUG":
|
||||||
|
# Only prcess DEBUG messages
|
||||||
|
return False
|
||||||
|
|
||||||
|
module = getattr(record, "module", None)
|
||||||
|
if not module:
|
||||||
|
# No module in record
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Process if this is a module we're tracking
|
||||||
|
if module in self.modules:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Process if this is a function we're tracking
|
||||||
|
if getattr(record, "funcName", None) in self.functions[module]:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
class LevelTagFilter(logging.Filter):
|
class LevelTagFilter(logging.Filter):
|
||||||
"""Add leveltag"""
|
"""Add leveltag"""
|
||||||
|
|
||||||
def filter(self, record: logging.LogRecord) -> bool:
|
def filter(self, record: logging.LogRecord) -> bool:
|
||||||
# Extract the first character of the level name
|
# Extract the first character of the level name
|
||||||
record.leveltag = record.levelname[0]
|
record.leveltag = record.levelname[0]
|
||||||
|
# We never actually filter messages out, just add an extra field
|
||||||
# We never actually filter messages out, just abuse filtering to add an
|
# to the LogRecord
|
||||||
# extra field to the LogRecord
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# Load YAML logging configuration
|
||||||
|
with open("app/logging.yaml", "r") as f:
|
||||||
|
config = yaml.safe_load(f)
|
||||||
|
logging.config.dictConfig(config)
|
||||||
|
|
||||||
|
# Get logger
|
||||||
log = logging.getLogger(Config.LOG_NAME)
|
log = logging.getLogger(Config.LOG_NAME)
|
||||||
log.setLevel(logging.DEBUG)
|
|
||||||
local_filter = LevelTagFilter()
|
|
||||||
|
|
||||||
# stderr
|
|
||||||
stderr = colorlog.StreamHandler()
|
|
||||||
stderr.setLevel(Config.LOG_LEVEL_STDERR)
|
|
||||||
stderr.addFilter(local_filter)
|
|
||||||
stderr_fmt = colorlog.ColoredFormatter(
|
|
||||||
"%(log_color)s[%(asctime)s] %(filename)s:%(lineno)s %(message)s", datefmt="%H:%M:%S"
|
|
||||||
)
|
|
||||||
stderr.setFormatter(stderr_fmt)
|
|
||||||
log.addHandler(stderr)
|
|
||||||
|
|
||||||
# syslog
|
|
||||||
syslog = logging.handlers.SysLogHandler(address="/dev/log")
|
|
||||||
syslog.setLevel(Config.LOG_LEVEL_SYSLOG)
|
|
||||||
syslog.addFilter(local_filter)
|
|
||||||
syslog_fmt = logging.Formatter(
|
|
||||||
"[%(name)s] %(filename)s:%(lineno)s %(leveltag)s: %(message)s"
|
|
||||||
)
|
|
||||||
syslog.setFormatter(syslog_fmt)
|
|
||||||
log.addHandler(syslog)
|
|
||||||
|
|
||||||
|
|
||||||
def log_uncaught_exceptions(type_, value, traceback):
|
def log_uncaught_exceptions(type_, value, traceback):
|
||||||
|
|||||||
52
app/logging.yaml
Normal file
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
|
# Standard library imports
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import List, Optional, Sequence
|
from typing import Optional, Sequence
|
||||||
import datetime as dt
|
import datetime as dt
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
@ -10,7 +10,6 @@ import sys
|
|||||||
# PyQt imports
|
# PyQt imports
|
||||||
|
|
||||||
# Third party imports
|
# Third party imports
|
||||||
import line_profiler
|
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
bindparam,
|
bindparam,
|
||||||
delete,
|
delete,
|
||||||
@ -188,7 +187,7 @@ class Playlists(dbtables.PlaylistsTable):
|
|||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def clear_tabs(session: Session, playlist_ids: List[int]) -> None:
|
def clear_tabs(session: Session, playlist_ids: list[int]) -> None:
|
||||||
"""
|
"""
|
||||||
Make all tab records NULL
|
Make all tab records NULL
|
||||||
"""
|
"""
|
||||||
@ -246,9 +245,7 @@ class Playlists(dbtables.PlaylistsTable):
|
|||||||
"""Returns a list of all templates ordered by name"""
|
"""Returns a list of all templates ordered by name"""
|
||||||
|
|
||||||
return session.scalars(
|
return session.scalars(
|
||||||
select(cls)
|
select(cls).where(cls.is_template.is_(True)).order_by(cls.name)
|
||||||
.where(cls.is_template.is_(True))
|
|
||||||
.order_by(cls.name)
|
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@ -428,7 +425,7 @@ class PlaylistRows(dbtables.PlaylistRowsTable):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def plrids_to_plrs(
|
def plrids_to_plrs(
|
||||||
cls, session: Session, playlist_id: int, plr_ids: List[int]
|
cls, session: Session, playlist_id: int, plr_ids: list[int]
|
||||||
) -> Sequence["PlaylistRows"]:
|
) -> Sequence["PlaylistRows"]:
|
||||||
"""
|
"""
|
||||||
Take a list of PlaylistRows ids and return a list of corresponding
|
Take a list of PlaylistRows ids and return a list of corresponding
|
||||||
@ -577,12 +574,10 @@ class PlaylistRows(dbtables.PlaylistRowsTable):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@line_profiler.profile
|
|
||||||
def update_plr_row_numbers(
|
def update_plr_row_numbers(
|
||||||
session: Session,
|
session: Session,
|
||||||
playlist_id: int,
|
playlist_id: int,
|
||||||
sqla_map: List[dict[str, int]],
|
sqla_map: list[dict[str, int]],
|
||||||
dummy_for_profiling: Optional[int] = None,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Take a {plrid: row_number} dictionary and update the row numbers accordingly
|
Take a {plrid: row_number} dictionary and update the row numbers accordingly
|
||||||
|
|||||||
@ -154,14 +154,11 @@ class _FadeCurve:
|
|||||||
|
|
||||||
if self.region is None:
|
if self.region is None:
|
||||||
# Create the region now that we're into fade
|
# Create the region now that we're into fade
|
||||||
log.debug("issue223: _FadeCurve: create region")
|
|
||||||
self.region = pg.LinearRegionItem([0, 0], bounds=[0, len(self.graph_array)])
|
self.region = pg.LinearRegionItem([0, 0], bounds=[0, len(self.graph_array)])
|
||||||
self.GraphWidget.addItem(self.region)
|
self.GraphWidget.addItem(self.region)
|
||||||
|
|
||||||
# Update region position
|
# Update region position
|
||||||
if self.region:
|
if self.region:
|
||||||
# Next line is very noisy
|
|
||||||
# log.debug("issue223: _FadeCurve: update region")
|
|
||||||
self.region.setRegion([0, ms_of_graph * self.ms_to_array_factor])
|
self.region.setRegion([0, ms_of_graph * self.ms_to_array_factor])
|
||||||
|
|
||||||
|
|
||||||
@ -578,7 +575,6 @@ class RowAndTrack:
|
|||||||
def play(self, position: Optional[float] = None) -> None:
|
def play(self, position: Optional[float] = None) -> None:
|
||||||
"""Play track"""
|
"""Play track"""
|
||||||
|
|
||||||
log.debug(f"issue223: RowAndTrack: play {self.track_id=}")
|
|
||||||
now = dt.datetime.now()
|
now = dt.datetime.now()
|
||||||
self.start_time = now
|
self.start_time = now
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
# Standard library imports
|
# Standard library imports
|
||||||
from slugify import slugify # type: ignore
|
from slugify import slugify # type: ignore
|
||||||
from typing import List, Optional
|
from typing import Optional
|
||||||
import argparse
|
import argparse
|
||||||
import datetime as dt
|
import datetime as dt
|
||||||
import os
|
import os
|
||||||
@ -44,7 +44,6 @@ from PyQt6.QtWidgets import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Third party imports
|
# Third party imports
|
||||||
import line_profiler
|
|
||||||
from pygame import mixer
|
from pygame import mixer
|
||||||
from sqlalchemy.orm.session import Session
|
from sqlalchemy.orm.session import Session
|
||||||
import stackprinter # type: ignore
|
import stackprinter # type: ignore
|
||||||
@ -393,7 +392,7 @@ class Window(QMainWindow, Ui_MainWindow):
|
|||||||
self.widgetFadeVolume.setDefaultPadding(0)
|
self.widgetFadeVolume.setDefaultPadding(0)
|
||||||
self.widgetFadeVolume.setBackground(Config.FADE_CURVE_BACKGROUND)
|
self.widgetFadeVolume.setBackground(Config.FADE_CURVE_BACKGROUND)
|
||||||
|
|
||||||
self.move_source_rows: Optional[List[int]] = None
|
self.move_source_rows: Optional[list[int]] = None
|
||||||
self.move_source_model: Optional[PlaylistModel] = None
|
self.move_source_model: Optional[PlaylistModel] = None
|
||||||
|
|
||||||
self.disable_selection_timing = False
|
self.disable_selection_timing = False
|
||||||
@ -534,7 +533,7 @@ class Window(QMainWindow, Ui_MainWindow):
|
|||||||
if current_track_playlist_id:
|
if current_track_playlist_id:
|
||||||
if closing_tab_playlist_id == current_track_playlist_id:
|
if closing_tab_playlist_id == current_track_playlist_id:
|
||||||
helpers.show_OK(
|
helpers.show_OK(
|
||||||
self, "Current track", "Can't close current track playlist"
|
"Current track", "Can't close current track playlist", self
|
||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@ -544,7 +543,7 @@ class Window(QMainWindow, Ui_MainWindow):
|
|||||||
if next_track_playlist_id:
|
if next_track_playlist_id:
|
||||||
if closing_tab_playlist_id == next_track_playlist_id:
|
if closing_tab_playlist_id == next_track_playlist_id:
|
||||||
helpers.show_OK(
|
helpers.show_OK(
|
||||||
self, "Next track", "Can't close next track playlist"
|
"Next track", "Can't close next track playlist", self
|
||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@ -571,18 +570,18 @@ class Window(QMainWindow, Ui_MainWindow):
|
|||||||
)
|
)
|
||||||
self.actionExport_playlist.triggered.connect(self.export_playlist_tab)
|
self.actionExport_playlist.triggered.connect(self.export_playlist_tab)
|
||||||
self.actionFade.triggered.connect(self.fade)
|
self.actionFade.triggered.connect(self.fade)
|
||||||
|
self.actionImport_files.triggered.connect(self.import_files_wrapper)
|
||||||
self.actionInsertSectionHeader.triggered.connect(self.insert_header)
|
self.actionInsertSectionHeader.triggered.connect(self.insert_header)
|
||||||
self.actionInsertTrack.triggered.connect(self.insert_track)
|
self.actionInsertTrack.triggered.connect(self.insert_track)
|
||||||
|
self.actionManage_templates.triggered.connect(self.manage_templates)
|
||||||
self.actionMark_for_moving.triggered.connect(self.mark_rows_for_moving)
|
self.actionMark_for_moving.triggered.connect(self.mark_rows_for_moving)
|
||||||
self.actionMoveSelected.triggered.connect(self.move_selected)
|
self.actionMoveSelected.triggered.connect(self.move_selected)
|
||||||
self.actionMoveUnplayed.triggered.connect(self.move_unplayed)
|
self.actionMoveUnplayed.triggered.connect(self.move_unplayed)
|
||||||
self.actionManage_templates.triggered.connect(self.manage_templates)
|
|
||||||
self.actionNewPlaylist.triggered.connect(self.new_playlist)
|
self.actionNewPlaylist.triggered.connect(self.new_playlist)
|
||||||
self.actionOpenPlaylist.triggered.connect(self.open_playlist)
|
self.actionOpenPlaylist.triggered.connect(self.open_playlist)
|
||||||
self.actionPaste.triggered.connect(self.paste_rows)
|
self.actionPaste.triggered.connect(self.paste_rows)
|
||||||
self.actionPlay_next.triggered.connect(self.play_next)
|
self.actionPlay_next.triggered.connect(self.play_next)
|
||||||
self.actionRenamePlaylist.triggered.connect(self.rename_playlist)
|
self.actionRenamePlaylist.triggered.connect(self.rename_playlist)
|
||||||
self.actionReplace_files.triggered.connect(self.import_files_wrapper)
|
|
||||||
self.actionResume.triggered.connect(self.resume)
|
self.actionResume.triggered.connect(self.resume)
|
||||||
self.actionSave_as_template.triggered.connect(self.save_as_template)
|
self.actionSave_as_template.triggered.connect(self.save_as_template)
|
||||||
self.actionSearch_title_in_Songfacts.triggered.connect(
|
self.actionSearch_title_in_Songfacts.triggered.connect(
|
||||||
@ -631,7 +630,9 @@ class Window(QMainWindow, Ui_MainWindow):
|
|||||||
self.signals.search_songfacts_signal.connect(self.open_songfacts_browser)
|
self.signals.search_songfacts_signal.connect(self.open_songfacts_browser)
|
||||||
self.signals.search_wikipedia_signal.connect(self.open_wikipedia_browser)
|
self.signals.search_wikipedia_signal.connect(self.open_wikipedia_browser)
|
||||||
|
|
||||||
def create_playlist(self, session: Session, playlist_name: str) -> Optional[Playlists]:
|
def create_playlist(
|
||||||
|
self, session: Session, playlist_name: str
|
||||||
|
) -> Optional[Playlists]:
|
||||||
"""Create new playlist"""
|
"""Create new playlist"""
|
||||||
|
|
||||||
log.debug(f"create_playlist({playlist_name=}")
|
log.debug(f"create_playlist({playlist_name=}")
|
||||||
@ -857,11 +858,8 @@ class Window(QMainWindow, Ui_MainWindow):
|
|||||||
|
|
||||||
# We need to keep a reference to the FileImporter else it will be
|
# We need to keep a reference to the FileImporter else it will be
|
||||||
# garbage collected while import threads are still running
|
# garbage collected while import threads are still running
|
||||||
self.importer = FileImporter(
|
self.importer = FileImporter(self.current.base_model, self.current_row_or_end())
|
||||||
self.current.base_model,
|
self.importer.start()
|
||||||
self.current_row_or_end()
|
|
||||||
)
|
|
||||||
self.importer.do_import()
|
|
||||||
|
|
||||||
def insert_header(self) -> None:
|
def insert_header(self) -> None:
|
||||||
"""Show dialog box to enter header text and add to playlist"""
|
"""Show dialog box to enter header text and add to playlist"""
|
||||||
@ -974,7 +972,9 @@ class Window(QMainWindow, Ui_MainWindow):
|
|||||||
playlist.delete(session)
|
playlist.delete(session)
|
||||||
session.commit()
|
session.commit()
|
||||||
else:
|
else:
|
||||||
raise ApplicationError(f"Unrecognised action from EditDeleteDialog: {action=}")
|
raise ApplicationError(
|
||||||
|
f"Unrecognised action from EditDeleteDialog: {action=}"
|
||||||
|
)
|
||||||
|
|
||||||
def mark_rows_for_moving(self) -> None:
|
def mark_rows_for_moving(self) -> None:
|
||||||
"""
|
"""
|
||||||
@ -990,7 +990,7 @@ class Window(QMainWindow, Ui_MainWindow):
|
|||||||
f"mark_rows_for_moving(): {self.move_source_rows=} {self.move_source_model=}"
|
f"mark_rows_for_moving(): {self.move_source_rows=} {self.move_source_model=}"
|
||||||
)
|
)
|
||||||
|
|
||||||
def move_playlist_rows(self, row_numbers: List[int]) -> None:
|
def move_playlist_rows(self, row_numbers: list[int]) -> None:
|
||||||
"""
|
"""
|
||||||
Move passed playlist rows to another playlist
|
Move passed playlist rows to another playlist
|
||||||
"""
|
"""
|
||||||
@ -1140,8 +1140,7 @@ class Window(QMainWindow, Ui_MainWindow):
|
|||||||
else:
|
else:
|
||||||
webbrowser.get("browser").open_new_tab(url)
|
webbrowser.get("browser").open_new_tab(url)
|
||||||
|
|
||||||
@line_profiler.profile
|
def paste_rows(self) -> None:
|
||||||
def paste_rows(self, dummy_for_profiling: Optional[int] = None) -> None:
|
|
||||||
"""
|
"""
|
||||||
Paste earlier cut rows.
|
Paste earlier cut rows.
|
||||||
"""
|
"""
|
||||||
@ -1193,8 +1192,6 @@ class Window(QMainWindow, Ui_MainWindow):
|
|||||||
- Update headers
|
- Update headers
|
||||||
"""
|
"""
|
||||||
|
|
||||||
log.debug(f"issue223: play_next({position=})")
|
|
||||||
|
|
||||||
# If there is no next track set, return.
|
# If there is no next track set, return.
|
||||||
if track_sequence.next is None:
|
if track_sequence.next is None:
|
||||||
log.error("musicmuster.play_next(): no next track selected")
|
log.error("musicmuster.play_next(): no next track selected")
|
||||||
@ -1205,10 +1202,9 @@ class Window(QMainWindow, Ui_MainWindow):
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Issue #223 concerns a very short pause (maybe 0.1s) sometimes
|
# Issue #223 concerns a very short pause (maybe 0.1s) sometimes
|
||||||
# when starting to play at track.
|
# when starting to play at track. Resolution appears to be to
|
||||||
|
# disable timer10 for a short time. Timer is re-enabled in
|
||||||
# Resolution appears to be to disable timer10 for a short time.
|
# update_clocks.
|
||||||
# Length of time and re-enabling of timer10 both in update_clocks.
|
|
||||||
|
|
||||||
self.timer10.stop()
|
self.timer10.stop()
|
||||||
log.debug("issue223: play_next: 10ms timer disabled")
|
log.debug("issue223: play_next: 10ms timer disabled")
|
||||||
@ -1227,38 +1223,29 @@ class Window(QMainWindow, Ui_MainWindow):
|
|||||||
|
|
||||||
# Restore volume if -3dB active
|
# Restore volume if -3dB active
|
||||||
if self.btnDrop3db.isChecked():
|
if self.btnDrop3db.isChecked():
|
||||||
log.debug("issue223: play_next: Reset -3db button")
|
|
||||||
self.btnDrop3db.setChecked(False)
|
self.btnDrop3db.setChecked(False)
|
||||||
|
|
||||||
# Play (new) current track
|
# Play (new) current track
|
||||||
log.info(f"Play: {track_sequence.current.title}")
|
log.debug(f"Play: {track_sequence.current.title}")
|
||||||
track_sequence.current.play(position)
|
track_sequence.current.play(position)
|
||||||
|
|
||||||
# Update clocks now, don't wait for next tick
|
# Update clocks now, don't wait for next tick
|
||||||
log.debug("issue223: play_next: update_clocks()")
|
|
||||||
self.update_clocks()
|
self.update_clocks()
|
||||||
|
|
||||||
# Show closing volume graph
|
# Show closing volume graph
|
||||||
if track_sequence.current.fade_graph:
|
if track_sequence.current.fade_graph:
|
||||||
log.debug(
|
|
||||||
f"issue223: play_next: set up fade_graph, {track_sequence.current.title=}"
|
|
||||||
)
|
|
||||||
track_sequence.current.fade_graph.GraphWidget = self.widgetFadeVolume
|
track_sequence.current.fade_graph.GraphWidget = self.widgetFadeVolume
|
||||||
track_sequence.current.fade_graph.clear()
|
track_sequence.current.fade_graph.clear()
|
||||||
track_sequence.current.fade_graph.plot()
|
track_sequence.current.fade_graph.plot()
|
||||||
else:
|
|
||||||
log.debug("issue223: play_next: No fade_graph")
|
|
||||||
|
|
||||||
# Disable play next controls
|
# Disable play next controls
|
||||||
self.catch_return_key = True
|
self.catch_return_key = True
|
||||||
self.show_status_message("Play controls: Disabled", 0)
|
self.show_status_message("Play controls: Disabled", 0)
|
||||||
|
|
||||||
# Notify playlist
|
# Notify playlist
|
||||||
log.debug("issue223: play_next: notify playlist")
|
|
||||||
self.active_tab().current_track_started()
|
self.active_tab().current_track_started()
|
||||||
|
|
||||||
# Update headers
|
# Update headers
|
||||||
log.debug("issue223: play_next: update headers")
|
|
||||||
self.update_headers()
|
self.update_headers()
|
||||||
with db.Session() as session:
|
with db.Session() as session:
|
||||||
last_played = Playdates.last_played_tracks(session)
|
last_played = Playdates.last_played_tracks(session)
|
||||||
@ -1478,11 +1465,9 @@ class Window(QMainWindow, Ui_MainWindow):
|
|||||||
helpers.show_warning(
|
helpers.show_warning(
|
||||||
self, "Duplicate template", "Template name already in use"
|
self, "Duplicate template", "Template name already in use"
|
||||||
)
|
)
|
||||||
Playlists.save_as_template(
|
Playlists.save_as_template(session, self.current.playlist_id, template_name)
|
||||||
session, self.current.playlist_id, template_name
|
|
||||||
)
|
|
||||||
session.commit()
|
session.commit()
|
||||||
helpers.show_OK(self, "Template", "Template saved")
|
helpers.show_OK("Template", "Template saved", self)
|
||||||
|
|
||||||
def search_playlist(self) -> None:
|
def search_playlist(self) -> None:
|
||||||
"""Show text box to search playlist"""
|
"""Show text box to search playlist"""
|
||||||
@ -1731,15 +1716,6 @@ class Window(QMainWindow, Ui_MainWindow):
|
|||||||
|
|
||||||
# If track is playing, update track clocks time and colours
|
# If track is playing, update track clocks time and colours
|
||||||
if track_sequence.current and track_sequence.current.is_playing():
|
if track_sequence.current and track_sequence.current.is_playing():
|
||||||
# see play_next() and issue #223.
|
|
||||||
# TODO: find a better way of handling this
|
|
||||||
if (
|
|
||||||
track_sequence.current.time_playing() > 5000
|
|
||||||
and not self.timer10.isActive()
|
|
||||||
):
|
|
||||||
self.timer10.start(10)
|
|
||||||
log.debug("issue223: update_clocks: 10ms timer enabled")
|
|
||||||
|
|
||||||
# Elapsed time
|
# Elapsed time
|
||||||
self.label_elapsed_timer.setText(
|
self.label_elapsed_timer.setText(
|
||||||
helpers.ms_to_mmss(track_sequence.current.time_playing())
|
helpers.ms_to_mmss(track_sequence.current.time_playing())
|
||||||
@ -1767,14 +1743,22 @@ class Window(QMainWindow, Ui_MainWindow):
|
|||||||
if self.frame_silent.styleSheet() != css_fade:
|
if self.frame_silent.styleSheet() != css_fade:
|
||||||
self.frame_silent.setStyleSheet(css_fade)
|
self.frame_silent.setStyleSheet(css_fade)
|
||||||
|
|
||||||
# Five seconds before fade starts, set warning colour on
|
# WARNING_MS_BEFORE_FADE milliseconds before fade starts, set
|
||||||
# time to silence box and enable play controls
|
# warning colour on time to silence box and enable play
|
||||||
|
# controls. This is also a good time to re-enable the 10ms
|
||||||
|
# timer (see play_next() and issue #223).
|
||||||
|
|
||||||
elif time_to_fade <= Config.WARNING_MS_BEFORE_FADE:
|
elif time_to_fade <= Config.WARNING_MS_BEFORE_FADE:
|
||||||
self.frame_fade.setStyleSheet(
|
self.frame_fade.setStyleSheet(
|
||||||
f"background: {Config.COLOUR_WARNING_TIMER}"
|
f"background: {Config.COLOUR_WARNING_TIMER}"
|
||||||
)
|
)
|
||||||
self.catch_return_key = False
|
self.catch_return_key = False
|
||||||
self.show_status_message("Play controls: Enabled", 0)
|
self.show_status_message("Play controls: Enabled", 0)
|
||||||
|
# Re-enable 10ms timer (see above)
|
||||||
|
if not self.timer10.isActive():
|
||||||
|
self.timer10.start(10)
|
||||||
|
log.debug("issue223: update_clocks: 10ms timer enabled")
|
||||||
|
|
||||||
else:
|
else:
|
||||||
self.frame_silent.setStyleSheet("")
|
self.frame_silent.setStyleSheet("")
|
||||||
self.frame_fade.setStyleSheet("")
|
self.frame_fade.setStyleSheet("")
|
||||||
|
|||||||
@ -26,7 +26,6 @@ from PyQt6.QtGui import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Third party imports
|
# Third party imports
|
||||||
import line_profiler
|
|
||||||
import obswebsocket # type: ignore
|
import obswebsocket # type: ignore
|
||||||
|
|
||||||
# import snoop # type: ignore
|
# import snoop # type: ignore
|
||||||
@ -297,22 +296,17 @@ class PlaylistModel(QAbstractTableModel):
|
|||||||
self.update_track_times()
|
self.update_track_times()
|
||||||
|
|
||||||
# Find next track
|
# Find next track
|
||||||
# Get all unplayed track rows
|
|
||||||
log.debug(f"{self}: Find next track")
|
|
||||||
next_row = None
|
next_row = None
|
||||||
unplayed_rows = self.get_unplayed_rows()
|
unplayed_rows = [
|
||||||
|
a
|
||||||
|
for a in self.get_unplayed_rows()
|
||||||
|
if not self.is_header_row(a)
|
||||||
|
and not file_is_unreadable(self.playlist_rows[a].path)
|
||||||
|
]
|
||||||
if unplayed_rows:
|
if unplayed_rows:
|
||||||
try:
|
try:
|
||||||
# Find next row after current track
|
next_row = min([a for a in unplayed_rows if a > row_number])
|
||||||
next_row = min(
|
|
||||||
[
|
|
||||||
a
|
|
||||||
for a in unplayed_rows
|
|
||||||
if a > row_number and not self.is_header_row(a)
|
|
||||||
]
|
|
||||||
)
|
|
||||||
except ValueError:
|
except ValueError:
|
||||||
# Find first unplayed track
|
|
||||||
next_row = min(unplayed_rows)
|
next_row = min(unplayed_rows)
|
||||||
if next_row is not None:
|
if next_row is not None:
|
||||||
self.set_next_row(next_row)
|
self.set_next_row(next_row)
|
||||||
@ -777,9 +771,7 @@ class PlaylistModel(QAbstractTableModel):
|
|||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def load_data(
|
def load_data(self, session: db.session) -> None:
|
||||||
self, session: db.session, dummy_for_profiling: Optional[int] = None
|
|
||||||
) -> None:
|
|
||||||
"""
|
"""
|
||||||
Same as refresh data, but only used when creating playslit.
|
Same as refresh data, but only used when creating playslit.
|
||||||
Distinguishes profile time between initial load and other
|
Distinguishes profile time between initial load and other
|
||||||
@ -826,13 +818,7 @@ class PlaylistModel(QAbstractTableModel):
|
|||||||
self.update_track_times()
|
self.update_track_times()
|
||||||
self.invalidate_rows(row_numbers)
|
self.invalidate_rows(row_numbers)
|
||||||
|
|
||||||
@line_profiler.profile
|
def move_rows(self, from_rows: list[int], to_row_number: int) -> None:
|
||||||
def move_rows(
|
|
||||||
self,
|
|
||||||
from_rows: list[int],
|
|
||||||
to_row_number: int,
|
|
||||||
dummy_for_profiling: Optional[int] = None,
|
|
||||||
) -> None:
|
|
||||||
"""
|
"""
|
||||||
Move the playlist rows given to to_row and below.
|
Move the playlist rows given to to_row and below.
|
||||||
"""
|
"""
|
||||||
@ -897,13 +883,11 @@ class PlaylistModel(QAbstractTableModel):
|
|||||||
self.update_track_times()
|
self.update_track_times()
|
||||||
self.invalidate_rows(list(row_map.keys()))
|
self.invalidate_rows(list(row_map.keys()))
|
||||||
|
|
||||||
@line_profiler.profile
|
|
||||||
def move_rows_between_playlists(
|
def move_rows_between_playlists(
|
||||||
self,
|
self,
|
||||||
from_rows: list[int],
|
from_rows: list[int],
|
||||||
to_row_number: int,
|
to_row_number: int,
|
||||||
to_playlist_id: int,
|
to_playlist_id: int,
|
||||||
dummy_for_profiling: Optional[int] = None,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Move the playlist rows given to to_row and below of to_playlist.
|
Move the playlist rows given to to_row and below of to_playlist.
|
||||||
@ -1046,7 +1030,7 @@ class PlaylistModel(QAbstractTableModel):
|
|||||||
log.debug(f"{self}: OBS scene changed to '{scene_name}'")
|
log.debug(f"{self}: OBS scene changed to '{scene_name}'")
|
||||||
continue
|
continue
|
||||||
except obswebsocket.exceptions.ConnectionFailure:
|
except obswebsocket.exceptions.ConnectionFailure:
|
||||||
log.error(f"{self}: OBS connection refused")
|
log.warning(f"{self}: OBS connection refused")
|
||||||
return
|
return
|
||||||
|
|
||||||
def previous_track_ended(self) -> None:
|
def previous_track_ended(self) -> None:
|
||||||
@ -1076,10 +1060,7 @@ class PlaylistModel(QAbstractTableModel):
|
|||||||
# Update display
|
# Update display
|
||||||
self.invalidate_row(track_sequence.previous.row_number)
|
self.invalidate_row(track_sequence.previous.row_number)
|
||||||
|
|
||||||
@line_profiler.profile
|
def refresh_data(self, session: db.session) -> None:
|
||||||
def refresh_data(
|
|
||||||
self, session: db.session, dummy_for_profiling: Optional[int] = None
|
|
||||||
) -> None:
|
|
||||||
"""
|
"""
|
||||||
Populate self.playlist_rows with playlist data
|
Populate self.playlist_rows with playlist data
|
||||||
|
|
||||||
@ -1170,6 +1151,7 @@ class PlaylistModel(QAbstractTableModel):
|
|||||||
]:
|
]:
|
||||||
if ts:
|
if ts:
|
||||||
ts.update_playlist_and_row(session)
|
ts.update_playlist_and_row(session)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
self.update_track_times()
|
self.update_track_times()
|
||||||
|
|
||||||
@ -1582,6 +1564,23 @@ class PlaylistModel(QAbstractTableModel):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def update_or_insert(self, track_id: int, row_number: int) -> None:
|
||||||
|
"""
|
||||||
|
If the passed track_id exists in this playlist, update the
|
||||||
|
row(s), otherwise insert this track at row_number.
|
||||||
|
"""
|
||||||
|
|
||||||
|
track_rows = [
|
||||||
|
a.row_number for a in self.playlist_rows.values() if a.track_id == track_id
|
||||||
|
]
|
||||||
|
if track_rows:
|
||||||
|
with db.Session() as session:
|
||||||
|
for row in track_rows:
|
||||||
|
self.refresh_row(session, row)
|
||||||
|
self.invalidate_rows(track_rows)
|
||||||
|
else:
|
||||||
|
self.insert_row(proposed_row_number=row_number, track_id=track_id)
|
||||||
|
|
||||||
def update_track_times(self) -> None:
|
def update_track_times(self) -> None:
|
||||||
"""
|
"""
|
||||||
Update track start/end times in self.playlist_rows
|
Update track start/end times in self.playlist_rows
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
# Standard library imports
|
# Standard library imports
|
||||||
from typing import Any, Callable, cast, List, Optional, TYPE_CHECKING
|
from typing import Any, Callable, cast, Optional, TYPE_CHECKING
|
||||||
|
|
||||||
# PyQt imports
|
# PyQt imports
|
||||||
from PyQt6.QtCore import (
|
from PyQt6.QtCore import (
|
||||||
@ -33,7 +33,6 @@ from PyQt6.QtWidgets import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Third party imports
|
# Third party imports
|
||||||
import line_profiler
|
|
||||||
|
|
||||||
# App imports
|
# App imports
|
||||||
from audacity_controller import AudacityController
|
from audacity_controller import AudacityController
|
||||||
@ -214,10 +213,10 @@ class PlaylistDelegate(QStyledItemDelegate):
|
|||||||
doc.setTextWidth(option.rect.width())
|
doc.setTextWidth(option.rect.width())
|
||||||
doc.setDefaultFont(option.font)
|
doc.setDefaultFont(option.font)
|
||||||
doc.setDocumentMargin(Config.ROW_PADDING)
|
doc.setDocumentMargin(Config.ROW_PADDING)
|
||||||
if '\n' in option.text:
|
if "\n" in option.text:
|
||||||
txt = option.text.replace('\n', '<br>')
|
txt = option.text.replace("\n", "<br>")
|
||||||
elif '\u2028' in option.text:
|
elif "\u2028" in option.text:
|
||||||
txt = option.text.replace('\u2028', '<br>')
|
txt = option.text.replace("\u2028", "<br>")
|
||||||
else:
|
else:
|
||||||
txt = option.text
|
txt = option.text
|
||||||
doc.setHtml(txt)
|
doc.setHtml(txt)
|
||||||
@ -378,10 +377,7 @@ class PlaylistTab(QTableView):
|
|||||||
# Deselect edited line
|
# Deselect edited line
|
||||||
self.clear_selection()
|
self.clear_selection()
|
||||||
|
|
||||||
@line_profiler.profile
|
def dropEvent(self, event: Optional[QDropEvent]) -> None:
|
||||||
def dropEvent(
|
|
||||||
self, event: Optional[QDropEvent], dummy_for_profiling: Optional[int] = None
|
|
||||||
) -> None:
|
|
||||||
"""
|
"""
|
||||||
Move dropped rows
|
Move dropped rows
|
||||||
"""
|
"""
|
||||||
@ -828,7 +824,7 @@ class PlaylistTab(QTableView):
|
|||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_selected_rows(self) -> List[int]:
|
def get_selected_rows(self) -> list[int]:
|
||||||
"""Return a list of model-selected row numbers sorted by row"""
|
"""Return a list of model-selected row numbers sorted by row"""
|
||||||
|
|
||||||
# Use a set to deduplicate result (a selected row will have all
|
# Use a set to deduplicate result (a selected row will have all
|
||||||
@ -880,9 +876,9 @@ class PlaylistTab(QTableView):
|
|||||||
else:
|
else:
|
||||||
txt = f"Can't find info about row{row_number}"
|
txt = f"Can't find info about row{row_number}"
|
||||||
|
|
||||||
show_OK(self.musicmuster, "Track info", txt)
|
show_OK("Track info", txt, self.musicmuster)
|
||||||
|
|
||||||
def _mark_as_unplayed(self, row_numbers: List[int]) -> None:
|
def _mark_as_unplayed(self, row_numbers: list[int]) -> None:
|
||||||
"""Mark row as unplayed"""
|
"""Mark row as unplayed"""
|
||||||
|
|
||||||
self.get_base_model().mark_unplayed(row_numbers)
|
self.get_base_model().mark_unplayed(row_numbers)
|
||||||
@ -1006,7 +1002,7 @@ class PlaylistTab(QTableView):
|
|||||||
return None
|
return None
|
||||||
return self.model().mapToSource(selected_index).row()
|
return self.model().mapToSource(selected_index).row()
|
||||||
|
|
||||||
def selected_model_row_numbers(self) -> List[int]:
|
def selected_model_row_numbers(self) -> list[int]:
|
||||||
"""
|
"""
|
||||||
Return a list of model row numbers corresponding to the selected rows or
|
Return a list of model row numbers corresponding to the selected rows or
|
||||||
an empty list.
|
an empty list.
|
||||||
@ -1035,7 +1031,7 @@ class PlaylistTab(QTableView):
|
|||||||
|
|
||||||
return row_indexes[0]
|
return row_indexes[0]
|
||||||
|
|
||||||
def _selected_row_indexes(self) -> List[QModelIndex]:
|
def _selected_row_indexes(self) -> list[QModelIndex]:
|
||||||
"""
|
"""
|
||||||
Return a list of indexes of column 0 of selected rows
|
Return a list of indexes of column 0 of selected rows
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -1000,7 +1000,7 @@ padding-left: 8px;</string>
|
|||||||
<addaction name="actionSave_as_template"/>
|
<addaction name="actionSave_as_template"/>
|
||||||
<addaction name="actionManage_templates"/>
|
<addaction name="actionManage_templates"/>
|
||||||
<addaction name="separator"/>
|
<addaction name="separator"/>
|
||||||
<addaction name="actionReplace_files"/>
|
<addaction name="actionImport_files"/>
|
||||||
<addaction name="separator"/>
|
<addaction name="separator"/>
|
||||||
<addaction name="actionE_xit"/>
|
<addaction name="actionE_xit"/>
|
||||||
</widget>
|
</widget>
|
||||||
@ -1364,7 +1364,7 @@ padding-left: 8px;</string>
|
|||||||
<string>Select duplicate rows...</string>
|
<string>Select duplicate rows...</string>
|
||||||
</property>
|
</property>
|
||||||
</action>
|
</action>
|
||||||
<action name="actionReplace_files">
|
<action name="actionImport_files">
|
||||||
<property name="text">
|
<property name="text">
|
||||||
<string>Import files...</string>
|
<string>Import files...</string>
|
||||||
</property>
|
</property>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
# Form implementation generated from reading ui file 'app/ui/main_window.ui'
|
# Form implementation generated from reading ui file 'app/ui/main_window.ui'
|
||||||
#
|
#
|
||||||
# Created by: PyQt6 UI code generator 6.7.1
|
# Created by: PyQt6 UI code generator 6.8.0
|
||||||
#
|
#
|
||||||
# WARNING: Any manual changes made to this file will be lost when pyuic6 is
|
# WARNING: Any manual changes made to this file will be lost when pyuic6 is
|
||||||
# run again. Do not edit this file unless you know what you are doing.
|
# run again. Do not edit this file unless you know what you are doing.
|
||||||
@ -529,8 +529,8 @@ class Ui_MainWindow(object):
|
|||||||
self.actionSearch_title_in_Songfacts.setObjectName("actionSearch_title_in_Songfacts")
|
self.actionSearch_title_in_Songfacts.setObjectName("actionSearch_title_in_Songfacts")
|
||||||
self.actionSelect_duplicate_rows = QtGui.QAction(parent=MainWindow)
|
self.actionSelect_duplicate_rows = QtGui.QAction(parent=MainWindow)
|
||||||
self.actionSelect_duplicate_rows.setObjectName("actionSelect_duplicate_rows")
|
self.actionSelect_duplicate_rows.setObjectName("actionSelect_duplicate_rows")
|
||||||
self.actionReplace_files = QtGui.QAction(parent=MainWindow)
|
self.actionImport_files = QtGui.QAction(parent=MainWindow)
|
||||||
self.actionReplace_files.setObjectName("actionReplace_files")
|
self.actionImport_files.setObjectName("actionImport_files")
|
||||||
self.menuFile.addSeparator()
|
self.menuFile.addSeparator()
|
||||||
self.menuFile.addAction(self.actionInsertTrack)
|
self.menuFile.addAction(self.actionInsertTrack)
|
||||||
self.menuFile.addAction(self.actionRemove)
|
self.menuFile.addAction(self.actionRemove)
|
||||||
@ -557,7 +557,7 @@ class Ui_MainWindow(object):
|
|||||||
self.menuPlaylist.addAction(self.actionSave_as_template)
|
self.menuPlaylist.addAction(self.actionSave_as_template)
|
||||||
self.menuPlaylist.addAction(self.actionManage_templates)
|
self.menuPlaylist.addAction(self.actionManage_templates)
|
||||||
self.menuPlaylist.addSeparator()
|
self.menuPlaylist.addSeparator()
|
||||||
self.menuPlaylist.addAction(self.actionReplace_files)
|
self.menuPlaylist.addAction(self.actionImport_files)
|
||||||
self.menuPlaylist.addSeparator()
|
self.menuPlaylist.addSeparator()
|
||||||
self.menuPlaylist.addAction(self.actionE_xit)
|
self.menuPlaylist.addAction(self.actionE_xit)
|
||||||
self.menuSearc_h.addAction(self.actionSetNext)
|
self.menuSearc_h.addAction(self.actionSetNext)
|
||||||
@ -676,6 +676,6 @@ class Ui_MainWindow(object):
|
|||||||
self.actionSearch_title_in_Songfacts.setText(_translate("MainWindow", "Search title in Songfacts"))
|
self.actionSearch_title_in_Songfacts.setText(_translate("MainWindow", "Search title in Songfacts"))
|
||||||
self.actionSearch_title_in_Songfacts.setShortcut(_translate("MainWindow", "Ctrl+S"))
|
self.actionSearch_title_in_Songfacts.setShortcut(_translate("MainWindow", "Ctrl+S"))
|
||||||
self.actionSelect_duplicate_rows.setText(_translate("MainWindow", "Select duplicate rows..."))
|
self.actionSelect_duplicate_rows.setText(_translate("MainWindow", "Select duplicate rows..."))
|
||||||
self.actionReplace_files.setText(_translate("MainWindow", "Import files..."))
|
self.actionImport_files.setText(_translate("MainWindow", "Import files..."))
|
||||||
from infotabs import InfoTabs # type: ignore
|
from infotabs import InfoTabs
|
||||||
from pyqtgraph import PlotWidget # type: ignore
|
from pyqtgraph import PlotWidget # type: ignore
|
||||||
|
|||||||
1213
poetry.lock
generated
1213
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@ -1,49 +1,52 @@
|
|||||||
[tool.poetry]
|
[project]
|
||||||
name = "musicmuster"
|
name = "musicmuster"
|
||||||
version = "1.7.5"
|
version = "4.1.10"
|
||||||
description = "Music player for internet radio"
|
description = "Music player for internet radio"
|
||||||
authors = ["Keith Edmunds <kae@midnighthax.com>"]
|
authors = [
|
||||||
|
{ name = "Keith Edmunds", email = "kae@midnighthax.com" }
|
||||||
|
]
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.11,<4.0"
|
||||||
|
dependencies = [
|
||||||
|
"alchemical>=1.0.2",
|
||||||
|
"alembic>=1.14.0",
|
||||||
|
"colorlog>=6.9.0",
|
||||||
|
"fuzzywuzzy>=0.18.0",
|
||||||
|
"mutagen>=1.47.0",
|
||||||
|
"mysqlclient>=2.2.5",
|
||||||
|
"obs-websocket-py>=1.0",
|
||||||
|
"psutil>=6.1.0",
|
||||||
|
"pydub>=0.25.1",
|
||||||
|
"pydymenu>=0.5.2",
|
||||||
|
"pyfzf>=0.3.1",
|
||||||
|
"pygame>=2.6.1",
|
||||||
|
"pyqt6>=6.7.1",
|
||||||
|
"pyqt6-webengine>=6.7.0",
|
||||||
|
"pyqtgraph>=0.13.3",
|
||||||
|
"python-levenshtein>=0.26.1",
|
||||||
|
"python-slugify>=8.0.4",
|
||||||
|
"python-vlc>=3.0.21203",
|
||||||
|
"SQLAlchemy>=2.0.36",
|
||||||
|
"stackprinter>=0.2.10",
|
||||||
|
"tinytag>=1.10.1",
|
||||||
|
"types-psutil>=6.0.0.20240621",
|
||||||
|
]
|
||||||
|
|
||||||
[tool.poetry.dependencies]
|
|
||||||
python = "^3.11"
|
|
||||||
tinytag = "^1.10.1"
|
|
||||||
SQLAlchemy = "^2.0.36"
|
|
||||||
python-vlc = "^3.0.21203"
|
|
||||||
mysqlclient = "^2.2.5"
|
|
||||||
mutagen = "^1.47.0"
|
|
||||||
alembic = "^1.14.0"
|
|
||||||
pydub = "^0.25.1"
|
|
||||||
python-slugify = "^8.0.4"
|
|
||||||
pyfzf = "^0.3.1"
|
|
||||||
pydymenu = "^0.5.2"
|
|
||||||
stackprinter = "^0.2.10"
|
|
||||||
pyqt6 = "^6.7.1"
|
|
||||||
pyqtgraph = "^0.13.3"
|
|
||||||
colorlog = "^6.9.0"
|
|
||||||
alchemical = "^1.0.2"
|
|
||||||
obs-websocket-py = "^1.0"
|
|
||||||
pygame = "^2.6.1"
|
|
||||||
psutil = "^6.1.0"
|
|
||||||
pyqt6-webengine = "^6.7.0"
|
|
||||||
fuzzywuzzy = "^0.18.0"
|
|
||||||
python-levenshtein = "^0.26.1"
|
|
||||||
|
|
||||||
[tool.poetry.dev-dependencies]
|
[tool.poetry]
|
||||||
ipdb = "^0.13.9"
|
package-mode = false
|
||||||
pytest-qt = "^4.4.0"
|
|
||||||
pydub-stubs = "^0.25.1"
|
|
||||||
line-profiler = "^4.1.3"
|
|
||||||
flakehell = "^0.9.0"
|
|
||||||
|
|
||||||
[tool.poetry.group.dev.dependencies]
|
[tool.poetry.group.dev.dependencies]
|
||||||
pudb = "*"
|
|
||||||
flakehell = "^0.9.0"
|
flakehell = "^0.9.0"
|
||||||
mypy = "^1.7.0"
|
ipdb = "^0.13.9"
|
||||||
pytest-cov = "^5.0.0"
|
line-profiler = "^4.2.0"
|
||||||
pytest = "^8.1.1"
|
mypy = "^1.15.0"
|
||||||
black = "^24.3.0"
|
pudb = "*"
|
||||||
types-psutil = "^6.0.0.20240621"
|
pydub-stubs = "^0.25.1"
|
||||||
pdbp = "^1.5.3"
|
pytest = "^8.3.4"
|
||||||
|
pytest-qt = "^4.4.0"
|
||||||
|
black = "^25.1.0"
|
||||||
|
pytest-cov = "^6.0.0"
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["poetry-core>=1.0.0"]
|
requires = ["poetry-core>=1.0.0"]
|
||||||
@ -65,3 +68,4 @@ filterwarnings = ["ignore:'audioop' is deprecated", "ignore:pkg_resources"]
|
|||||||
exclude = ["migrations", "app/ui", "archive"]
|
exclude = ["migrations", "app/ui", "archive"]
|
||||||
paths = ["app"]
|
paths = ["app"]
|
||||||
make_whitelist = true
|
make_whitelist = true
|
||||||
|
|
||||||
|
|||||||
489
tests/test_file_importer.py
Normal file
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
|
import unittest
|
||||||
|
|
||||||
# PyQt imports
|
# PyQt imports
|
||||||
from PyQt6.QtCore import Qt
|
|
||||||
from PyQt6.QtGui import QColor
|
|
||||||
|
|
||||||
# Third party imports
|
# Third party imports
|
||||||
import pytest
|
import pytest
|
||||||
from pytestqt.plugin import QtBot # type: ignore
|
from pytestqt.plugin import QtBot # type: ignore
|
||||||
|
|
||||||
# App imports
|
# App imports
|
||||||
from config import Config
|
|
||||||
from app import playlistmodel, utilities
|
from app import playlistmodel, utilities
|
||||||
from app.models import (
|
from app.models import (
|
||||||
db,
|
db,
|
||||||
NoteColours,
|
|
||||||
Playlists,
|
Playlists,
|
||||||
Tracks,
|
Tracks,
|
||||||
)
|
)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user