Compare commits
No commits in common. "256de377cf5448f930dbca9273cd69286d41cf3b" and "85cfebe0f71af3609372cccb55551ccd2b7c3bac" have entirely different histories.
256de377cf
...
85cfebe0f7
@ -1,10 +1,9 @@
|
|||||||
# Standard library imports
|
# Standard library imports
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
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
|
||||||
@ -35,18 +34,12 @@ 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 wrapper_singleton.instance is None:
|
if not wrapper_singleton.instance:
|
||||||
with lock:
|
wrapper_singleton.instance = cls(*args, **kwargs)
|
||||||
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
|
||||||
@ -101,10 +94,10 @@ class MusicMusterSignals(QObject):
|
|||||||
|
|
||||||
|
|
||||||
class Tags(NamedTuple):
|
class Tags(NamedTuple):
|
||||||
artist: str = ""
|
artist: str
|
||||||
title: str = ""
|
title: str
|
||||||
bitrate: int = 0
|
bitrate: int
|
||||||
duration: int = 0
|
duration: int
|
||||||
|
|
||||||
|
|
||||||
class TrackInfo(NamedTuple):
|
class TrackInfo(NamedTuple):
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
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
|
||||||
|
|
||||||
@ -34,6 +35,8 @@ 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"
|
||||||
@ -48,10 +51,6 @@ 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"
|
||||||
@ -63,8 +62,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_SECTIONS = "SECTIONS"
|
|
||||||
HIDE_PLAYED_MODE_TRACKS = "TRACKS"
|
HIDE_PLAYED_MODE_TRACKS = "TRACKS"
|
||||||
|
HIDE_PLAYED_MODE_SECTIONS = "SECTIONS"
|
||||||
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"
|
||||||
@ -80,10 +79,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 Optional
|
from typing import List, 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=True)
|
substring: Mapped[str] = mapped_column(String(256), index=False)
|
||||||
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,16 +146,13 @@ 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",
|
"PlaylistRowsTable", back_populates="track"
|
||||||
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,5 +1,6 @@
|
|||||||
# 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
|
||||||
@ -8,22 +9,27 @@ 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 Settings, Tracks
|
from models import db, Settings, Tracks
|
||||||
from playlistmodel import PlaylistModel
|
from playlistmodel import PlaylistModel
|
||||||
from ui import dlg_TrackSelect_ui
|
from ui import dlg_TrackSelect_ui, dlg_replace_files_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, QWidget
|
from PyQt6.QtWidgets import QMainWindow, QMessageBox
|
||||||
|
|
||||||
# Third party imports
|
# Third party imports
|
||||||
from mutagen.flac import FLAC # type: ignore
|
from mutagen.flac import FLAC # type: ignore
|
||||||
@ -200,17 +200,9 @@ 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: {path}")
|
raise ApplicationError(f"File not found: get_tags({path=})")
|
||||||
except TinyTagException:
|
except TinyTagException:
|
||||||
raise ApplicationError(f"Can't read tags in {path}")
|
raise ApplicationError(f"Can't read tags: get_tags({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,
|
||||||
@ -400,16 +392,10 @@ 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(title: str, msg: str, parent: Optional[QWidget] = None) -> None:
|
def show_OK(parent: QMainWindow, title: str, msg: str) -> None:
|
||||||
"""Display a message to user"""
|
"""Display a message to user"""
|
||||||
|
|
||||||
dlg = QMessageBox(parent)
|
QMessageBox.information(parent, title, msg, buttons=QMessageBox.StandardButton.Ok)
|
||||||
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,79 +1,56 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/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
|
|
||||||
# to the LogRecord
|
# We never actually filter messages out, just abuse filtering to add an
|
||||||
|
# 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):
|
||||||
|
|||||||
@ -1,52 +0,0 @@
|
|||||||
version: 1
|
|
||||||
disable_existing_loggers: True
|
|
||||||
|
|
||||||
formatters:
|
|
||||||
colored:
|
|
||||||
(): colorlog.ColoredFormatter
|
|
||||||
format: "%(log_color)s[%(asctime)s] %(filename)s.%(funcName)s:%(lineno)s %(blue)s%(message)s"
|
|
||||||
datefmt: "%H:%M:%S"
|
|
||||||
syslog:
|
|
||||||
format: "[%(name)s] %(filename)s:%(lineno)s %(leveltag)s: %(message)s"
|
|
||||||
|
|
||||||
filters:
|
|
||||||
leveltag:
|
|
||||||
(): log.LevelTagFilter
|
|
||||||
category_filter:
|
|
||||||
(): log.FunctionFilter
|
|
||||||
module_functions:
|
|
||||||
# Optionally additionally log some debug calls to stderr
|
|
||||||
# log all debug calls in a module:
|
|
||||||
# module-name: []
|
|
||||||
# log debug calls for some functions in a module:
|
|
||||||
# module-name:
|
|
||||||
# - function-name-1
|
|
||||||
# - function-name-2
|
|
||||||
|
|
||||||
handlers:
|
|
||||||
stderr:
|
|
||||||
class: colorlog.StreamHandler
|
|
||||||
level: INFO
|
|
||||||
formatter: colored
|
|
||||||
filters: [leveltag]
|
|
||||||
stream: ext://sys.stderr
|
|
||||||
|
|
||||||
syslog:
|
|
||||||
class: logging.handlers.SysLogHandler
|
|
||||||
level: DEBUG
|
|
||||||
formatter: syslog
|
|
||||||
filters: [leveltag]
|
|
||||||
address: "/dev/log"
|
|
||||||
|
|
||||||
debug_stderr:
|
|
||||||
class: colorlog.StreamHandler
|
|
||||||
level: DEBUG
|
|
||||||
formatter: colored
|
|
||||||
filters: [leveltag, category_filter]
|
|
||||||
stream: ext://sys.stderr
|
|
||||||
|
|
||||||
loggers:
|
|
||||||
musicmuster:
|
|
||||||
level: DEBUG
|
|
||||||
handlers: [stderr, syslog, debug_stderr]
|
|
||||||
propagate: false
|
|
||||||
@ -1,29 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
from log import log
|
|
||||||
|
|
||||||
|
|
||||||
# Testing
|
|
||||||
def fa():
|
|
||||||
log.debug("fa Debug message")
|
|
||||||
log.info("fa Info message")
|
|
||||||
log.warning("fa Warning message")
|
|
||||||
log.error("fa Error message")
|
|
||||||
log.critical("fa Critical message")
|
|
||||||
print()
|
|
||||||
|
|
||||||
|
|
||||||
def fb():
|
|
||||||
log.debug("fb Debug message")
|
|
||||||
log.info("fb Info message")
|
|
||||||
log.warning("fb Warning message")
|
|
||||||
log.error("fb Error message")
|
|
||||||
log.critical("fb Critical message")
|
|
||||||
print()
|
|
||||||
|
|
||||||
|
|
||||||
def testing():
|
|
||||||
fa()
|
|
||||||
fb()
|
|
||||||
|
|
||||||
|
|
||||||
testing()
|
|
||||||
@ -1,7 +1,7 @@
|
|||||||
# Standard library imports
|
# Standard library imports
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Optional, Sequence
|
from typing import List, Optional, Sequence
|
||||||
import datetime as dt
|
import datetime as dt
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
@ -10,6 +10,7 @@ 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,
|
||||||
@ -187,7 +188,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
|
||||||
"""
|
"""
|
||||||
@ -245,7 +246,9 @@ 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).where(cls.is_template.is_(True)).order_by(cls.name)
|
select(cls)
|
||||||
|
.where(cls.is_template.is_(True))
|
||||||
|
.order_by(cls.name)
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@ -425,7 +428,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
|
||||||
@ -574,10 +577,12 @@ 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,11 +154,14 @@ 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])
|
||||||
|
|
||||||
|
|
||||||
@ -575,6 +578,7 @@ 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 Optional
|
from typing import List, Optional
|
||||||
import argparse
|
import argparse
|
||||||
import datetime as dt
|
import datetime as dt
|
||||||
import os
|
import os
|
||||||
@ -44,6 +44,7 @@ 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
|
||||||
@ -392,7 +393,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
|
||||||
@ -533,7 +534,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(
|
||||||
"Current track", "Can't close current track playlist", self
|
self, "Current track", "Can't close current track playlist"
|
||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@ -543,7 +544,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(
|
||||||
"Next track", "Can't close next track playlist", self
|
self, "Next track", "Can't close next track playlist"
|
||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@ -570,18 +571,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(
|
||||||
@ -630,9 +631,7 @@ 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(
|
def create_playlist(self, session: Session, playlist_name: str) -> Optional[Playlists]:
|
||||||
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=}")
|
||||||
@ -858,8 +857,11 @@ 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.current.base_model, self.current_row_or_end())
|
self.importer = FileImporter(
|
||||||
self.importer.start()
|
self.current.base_model,
|
||||||
|
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"""
|
||||||
@ -972,9 +974,7 @@ class Window(QMainWindow, Ui_MainWindow):
|
|||||||
playlist.delete(session)
|
playlist.delete(session)
|
||||||
session.commit()
|
session.commit()
|
||||||
else:
|
else:
|
||||||
raise ApplicationError(
|
raise ApplicationError(f"Unrecognised action from EditDeleteDialog: {action=}")
|
||||||
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,7 +1140,8 @@ class Window(QMainWindow, Ui_MainWindow):
|
|||||||
else:
|
else:
|
||||||
webbrowser.get("browser").open_new_tab(url)
|
webbrowser.get("browser").open_new_tab(url)
|
||||||
|
|
||||||
def paste_rows(self) -> None:
|
@line_profiler.profile
|
||||||
|
def paste_rows(self, dummy_for_profiling: Optional[int] = None) -> None:
|
||||||
"""
|
"""
|
||||||
Paste earlier cut rows.
|
Paste earlier cut rows.
|
||||||
"""
|
"""
|
||||||
@ -1192,6 +1193,8 @@ 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")
|
||||||
@ -1202,9 +1205,10 @@ 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. Resolution appears to be to
|
# when starting to play at track.
|
||||||
# disable timer10 for a short time. Timer is re-enabled in
|
|
||||||
# update_clocks.
|
# Resolution appears to be to disable timer10 for a short time.
|
||||||
|
# Length of time and re-enabling of timer10 both in update_clocks.
|
||||||
|
|
||||||
self.timer10.stop()
|
self.timer10.stop()
|
||||||
log.debug("issue223: play_next: 10ms timer disabled")
|
log.debug("issue223: play_next: 10ms timer disabled")
|
||||||
@ -1223,29 +1227,38 @@ 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.debug(f"Play: {track_sequence.current.title}")
|
log.info(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)
|
||||||
@ -1465,9 +1478,11 @@ 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(session, self.current.playlist_id, template_name)
|
Playlists.save_as_template(
|
||||||
|
session, self.current.playlist_id, template_name
|
||||||
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
helpers.show_OK("Template", "Template saved", self)
|
helpers.show_OK(self, "Template", "Template saved")
|
||||||
|
|
||||||
def search_playlist(self) -> None:
|
def search_playlist(self) -> None:
|
||||||
"""Show text box to search playlist"""
|
"""Show text box to search playlist"""
|
||||||
@ -1716,6 +1731,15 @@ class Window(QMainWindow, Ui_MainWindow):
|
|||||||
|
|
||||||
# If track is playing, update track clocks time and colours
|
# If track 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())
|
||||||
@ -1743,22 +1767,14 @@ 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)
|
||||||
|
|
||||||
# WARNING_MS_BEFORE_FADE milliseconds before fade starts, set
|
# Five seconds before fade starts, set warning colour on
|
||||||
# warning colour on time to silence box and enable play
|
# time to silence box and enable play controls
|
||||||
# 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,6 +26,7 @@ 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
|
||||||
@ -296,17 +297,22 @@ 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 = [
|
unplayed_rows = self.get_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:
|
||||||
next_row = min([a for a in unplayed_rows if a > row_number])
|
# Find next row after current track
|
||||||
|
next_row = min(
|
||||||
|
[
|
||||||
|
a
|
||||||
|
for a in unplayed_rows
|
||||||
|
if a > row_number and not self.is_header_row(a)
|
||||||
|
]
|
||||||
|
)
|
||||||
except ValueError:
|
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)
|
||||||
@ -771,7 +777,9 @@ class PlaylistModel(QAbstractTableModel):
|
|||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def load_data(self, session: db.session) -> None:
|
def load_data(
|
||||||
|
self, session: db.session, dummy_for_profiling: Optional[int] = None
|
||||||
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Same as refresh data, but only used when creating playslit.
|
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
|
||||||
@ -818,7 +826,13 @@ class PlaylistModel(QAbstractTableModel):
|
|||||||
self.update_track_times()
|
self.update_track_times()
|
||||||
self.invalidate_rows(row_numbers)
|
self.invalidate_rows(row_numbers)
|
||||||
|
|
||||||
def move_rows(self, from_rows: list[int], to_row_number: int) -> None:
|
@line_profiler.profile
|
||||||
|
def move_rows(
|
||||||
|
self,
|
||||||
|
from_rows: list[int],
|
||||||
|
to_row_number: int,
|
||||||
|
dummy_for_profiling: Optional[int] = None,
|
||||||
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Move the playlist rows given to to_row and below.
|
Move the playlist rows given to to_row and below.
|
||||||
"""
|
"""
|
||||||
@ -883,11 +897,13 @@ 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.
|
||||||
@ -1030,7 +1046,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.warning(f"{self}: OBS connection refused")
|
log.error(f"{self}: OBS connection refused")
|
||||||
return
|
return
|
||||||
|
|
||||||
def previous_track_ended(self) -> None:
|
def previous_track_ended(self) -> None:
|
||||||
@ -1060,7 +1076,10 @@ class PlaylistModel(QAbstractTableModel):
|
|||||||
# Update display
|
# Update display
|
||||||
self.invalidate_row(track_sequence.previous.row_number)
|
self.invalidate_row(track_sequence.previous.row_number)
|
||||||
|
|
||||||
def refresh_data(self, session: db.session) -> None:
|
@line_profiler.profile
|
||||||
|
def refresh_data(
|
||||||
|
self, session: db.session, dummy_for_profiling: Optional[int] = None
|
||||||
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Populate self.playlist_rows with playlist data
|
Populate self.playlist_rows with playlist data
|
||||||
|
|
||||||
@ -1151,7 +1170,6 @@ 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()
|
||||||
|
|
||||||
@ -1564,23 +1582,6 @@ class PlaylistModel(QAbstractTableModel):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
def update_or_insert(self, track_id: int, row_number: int) -> None:
|
|
||||||
"""
|
|
||||||
If the passed track_id exists in this playlist, update the
|
|
||||||
row(s), otherwise insert this track at row_number.
|
|
||||||
"""
|
|
||||||
|
|
||||||
track_rows = [
|
|
||||||
a.row_number for a in self.playlist_rows.values() if a.track_id == track_id
|
|
||||||
]
|
|
||||||
if track_rows:
|
|
||||||
with db.Session() as session:
|
|
||||||
for row in track_rows:
|
|
||||||
self.refresh_row(session, row)
|
|
||||||
self.invalidate_rows(track_rows)
|
|
||||||
else:
|
|
||||||
self.insert_row(proposed_row_number=row_number, track_id=track_id)
|
|
||||||
|
|
||||||
def update_track_times(self) -> None:
|
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, Optional, TYPE_CHECKING
|
from typing import Any, Callable, cast, List, Optional, TYPE_CHECKING
|
||||||
|
|
||||||
# PyQt imports
|
# PyQt imports
|
||||||
from PyQt6.QtCore import (
|
from PyQt6.QtCore import (
|
||||||
@ -33,6 +33,7 @@ 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
|
||||||
@ -213,10 +214,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)
|
||||||
@ -377,7 +378,10 @@ class PlaylistTab(QTableView):
|
|||||||
# Deselect edited line
|
# Deselect edited line
|
||||||
self.clear_selection()
|
self.clear_selection()
|
||||||
|
|
||||||
def dropEvent(self, event: Optional[QDropEvent]) -> None:
|
@line_profiler.profile
|
||||||
|
def dropEvent(
|
||||||
|
self, event: Optional[QDropEvent], dummy_for_profiling: Optional[int] = None
|
||||||
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Move dropped rows
|
Move dropped rows
|
||||||
"""
|
"""
|
||||||
@ -824,7 +828,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
|
||||||
@ -876,9 +880,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("Track info", txt, self.musicmuster)
|
show_OK(self.musicmuster, "Track info", txt)
|
||||||
|
|
||||||
def _mark_as_unplayed(self, row_numbers: list[int]) -> None:
|
def _mark_as_unplayed(self, row_numbers: List[int]) -> None:
|
||||||
"""Mark row as unplayed"""
|
"""Mark row as unplayed"""
|
||||||
|
|
||||||
self.get_base_model().mark_unplayed(row_numbers)
|
self.get_base_model().mark_unplayed(row_numbers)
|
||||||
@ -1002,7 +1006,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.
|
||||||
@ -1031,7 +1035,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="actionImport_files"/>
|
<addaction name="actionReplace_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="actionImport_files">
|
<action name="actionReplace_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.8.0
|
# Created by: PyQt6 UI code generator 6.7.1
|
||||||
#
|
#
|
||||||
# WARNING: Any manual changes made to this file will be lost when pyuic6 is
|
# 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.actionImport_files = QtGui.QAction(parent=MainWindow)
|
self.actionReplace_files = QtGui.QAction(parent=MainWindow)
|
||||||
self.actionImport_files.setObjectName("actionImport_files")
|
self.actionReplace_files.setObjectName("actionReplace_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.actionImport_files)
|
self.menuPlaylist.addAction(self.actionReplace_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.actionImport_files.setText(_translate("MainWindow", "Import files..."))
|
self.actionReplace_files.setText(_translate("MainWindow", "Import files..."))
|
||||||
from infotabs import InfoTabs
|
from infotabs import InfoTabs # type: ignore
|
||||||
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,52 +1,49 @@
|
|||||||
[project]
|
|
||||||
name = "musicmuster"
|
|
||||||
version = "4.1.10"
|
|
||||||
description = "Music player for internet radio"
|
|
||||||
authors = [
|
|
||||||
{ name = "Keith Edmunds", email = "kae@midnighthax.com" }
|
|
||||||
]
|
|
||||||
readme = "README.md"
|
|
||||||
requires-python = ">=3.11,<4.0"
|
|
||||||
dependencies = [
|
|
||||||
"alchemical>=1.0.2",
|
|
||||||
"alembic>=1.14.0",
|
|
||||||
"colorlog>=6.9.0",
|
|
||||||
"fuzzywuzzy>=0.18.0",
|
|
||||||
"mutagen>=1.47.0",
|
|
||||||
"mysqlclient>=2.2.5",
|
|
||||||
"obs-websocket-py>=1.0",
|
|
||||||
"psutil>=6.1.0",
|
|
||||||
"pydub>=0.25.1",
|
|
||||||
"pydymenu>=0.5.2",
|
|
||||||
"pyfzf>=0.3.1",
|
|
||||||
"pygame>=2.6.1",
|
|
||||||
"pyqt6>=6.7.1",
|
|
||||||
"pyqt6-webengine>=6.7.0",
|
|
||||||
"pyqtgraph>=0.13.3",
|
|
||||||
"python-levenshtein>=0.26.1",
|
|
||||||
"python-slugify>=8.0.4",
|
|
||||||
"python-vlc>=3.0.21203",
|
|
||||||
"SQLAlchemy>=2.0.36",
|
|
||||||
"stackprinter>=0.2.10",
|
|
||||||
"tinytag>=1.10.1",
|
|
||||||
"types-psutil>=6.0.0.20240621",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
package-mode = false
|
name = "musicmuster"
|
||||||
|
version = "1.7.5"
|
||||||
|
description = "Music player for internet radio"
|
||||||
|
authors = ["Keith Edmunds <kae@midnighthax.com>"]
|
||||||
|
|
||||||
|
[tool.poetry.dependencies]
|
||||||
|
python = "^3.11"
|
||||||
|
tinytag = "^1.10.1"
|
||||||
|
SQLAlchemy = "^2.0.36"
|
||||||
|
python-vlc = "^3.0.21203"
|
||||||
|
mysqlclient = "^2.2.5"
|
||||||
|
mutagen = "^1.47.0"
|
||||||
|
alembic = "^1.14.0"
|
||||||
|
pydub = "^0.25.1"
|
||||||
|
python-slugify = "^8.0.4"
|
||||||
|
pyfzf = "^0.3.1"
|
||||||
|
pydymenu = "^0.5.2"
|
||||||
|
stackprinter = "^0.2.10"
|
||||||
|
pyqt6 = "^6.7.1"
|
||||||
|
pyqtgraph = "^0.13.3"
|
||||||
|
colorlog = "^6.9.0"
|
||||||
|
alchemical = "^1.0.2"
|
||||||
|
obs-websocket-py = "^1.0"
|
||||||
|
pygame = "^2.6.1"
|
||||||
|
psutil = "^6.1.0"
|
||||||
|
pyqt6-webengine = "^6.7.0"
|
||||||
|
fuzzywuzzy = "^0.18.0"
|
||||||
|
python-levenshtein = "^0.26.1"
|
||||||
|
|
||||||
|
[tool.poetry.dev-dependencies]
|
||||||
|
ipdb = "^0.13.9"
|
||||||
|
pytest-qt = "^4.4.0"
|
||||||
|
pydub-stubs = "^0.25.1"
|
||||||
|
line-profiler = "^4.1.3"
|
||||||
|
flakehell = "^0.9.0"
|
||||||
|
|
||||||
[tool.poetry.group.dev.dependencies]
|
[tool.poetry.group.dev.dependencies]
|
||||||
flakehell = "^0.9.0"
|
|
||||||
ipdb = "^0.13.9"
|
|
||||||
line-profiler = "^4.2.0"
|
|
||||||
mypy = "^1.15.0"
|
|
||||||
pudb = "*"
|
pudb = "*"
|
||||||
pydub-stubs = "^0.25.1"
|
flakehell = "^0.9.0"
|
||||||
pytest = "^8.3.4"
|
mypy = "^1.7.0"
|
||||||
pytest-qt = "^4.4.0"
|
pytest-cov = "^5.0.0"
|
||||||
black = "^25.1.0"
|
pytest = "^8.1.1"
|
||||||
pytest-cov = "^6.0.0"
|
black = "^24.3.0"
|
||||||
|
types-psutil = "^6.0.0.20240621"
|
||||||
|
pdbp = "^1.5.3"
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["poetry-core>=1.0.0"]
|
requires = ["poetry-core>=1.0.0"]
|
||||||
@ -68,4 +65,3 @@ 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
|
||||||
|
|
||||||
|
|||||||
@ -1,489 +0,0 @@
|
|||||||
"""
|
|
||||||
Tests are named 'test_nnn_xxxx' where 'nn n' is a number. This is used to ensure that
|
|
||||||
the tests run in order as we rely (in some cases) upon the results of an earlier test.
|
|
||||||
Yes, we shouldn't do that.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Standard library imports
|
|
||||||
import os
|
|
||||||
import shutil
|
|
||||||
import tempfile
|
|
||||||
import unittest
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
# PyQt imports
|
|
||||||
from PyQt6.QtWidgets import QDialog, QFileDialog
|
|
||||||
|
|
||||||
# Third party imports
|
|
||||||
from mutagen.mp3 import MP3 # type: ignore
|
|
||||||
import pytest
|
|
||||||
from pytestqt.plugin import QtBot # type: ignore
|
|
||||||
|
|
||||||
# App imports
|
|
||||||
from app import musicmuster
|
|
||||||
from app.models import (
|
|
||||||
db,
|
|
||||||
Playlists,
|
|
||||||
Tracks,
|
|
||||||
)
|
|
||||||
from config import Config
|
|
||||||
from file_importer import FileImporter
|
|
||||||
|
|
||||||
|
|
||||||
# Custom fixture to adapt qtbot for use with unittest.TestCase
|
|
||||||
@pytest.fixture(scope="class")
|
|
||||||
def qtbot_adapter(qapp, request):
|
|
||||||
"""Adapt qtbot fixture for usefixtures and unittest.TestCase"""
|
|
||||||
request.cls.qtbot = QtBot(request)
|
|
||||||
|
|
||||||
|
|
||||||
# Fixture for tmp_path to be available in the class
|
|
||||||
@pytest.fixture(scope="class")
|
|
||||||
def class_tmp_path(request, tmp_path_factory):
|
|
||||||
"""Provide a class-wide tmp_path"""
|
|
||||||
request.cls.tmp_path = tmp_path_factory.mktemp("pytest_tmp")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.usefixtures("qtbot_adapter", "class_tmp_path")
|
|
||||||
class MyTestCase(unittest.TestCase):
|
|
||||||
@classmethod
|
|
||||||
def setUpClass(cls):
|
|
||||||
"""Runs once before any test in this class"""
|
|
||||||
|
|
||||||
db.create_all()
|
|
||||||
|
|
||||||
cls.widget = musicmuster.Window()
|
|
||||||
|
|
||||||
# Create a playlist for all tests
|
|
||||||
playlist_name = "file importer playlist"
|
|
||||||
with db.Session() as session:
|
|
||||||
playlist = Playlists(session, playlist_name)
|
|
||||||
cls.widget.create_playlist_tab(playlist)
|
|
||||||
|
|
||||||
# Create our musicstore
|
|
||||||
cls.import_source = tempfile.mkdtemp(suffix="_MMsource_pytest", dir="/tmp")
|
|
||||||
Config.REPLACE_FILES_DEFAULT_SOURCE = cls.import_source
|
|
||||||
cls.musicstore = tempfile.mkdtemp(suffix="_MMstore_pytest", dir="/tmp")
|
|
||||||
Config.IMPORT_DESTINATION = cls.musicstore
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def tearDownClass(cls):
|
|
||||||
"""Runs once after all tests"""
|
|
||||||
|
|
||||||
db.drop_all()
|
|
||||||
shutil.rmtree(cls.musicstore)
|
|
||||||
shutil.rmtree(cls.import_source)
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
"""Runs before each test"""
|
|
||||||
|
|
||||||
with self.qtbot.waitExposed(self.widget):
|
|
||||||
self.widget.show()
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
"""Runs after each test"""
|
|
||||||
self.widget.close() # Close UI to prevent side effects
|
|
||||||
|
|
||||||
def wait_for_workers(self, timeout: int = 10000):
|
|
||||||
"""
|
|
||||||
Let import threads workers run to completion
|
|
||||||
"""
|
|
||||||
|
|
||||||
def workers_empty():
|
|
||||||
assert FileImporter.workers == {}
|
|
||||||
|
|
||||||
self.qtbot.waitUntil(workers_empty, timeout=timeout)
|
|
||||||
|
|
||||||
def test_001_import_no_files(self):
|
|
||||||
"""Try importing with no files to import"""
|
|
||||||
|
|
||||||
with patch("file_importer.show_OK") as mock_show_ok:
|
|
||||||
self.widget.import_files_wrapper()
|
|
||||||
mock_show_ok.assert_called_once_with(
|
|
||||||
"File import",
|
|
||||||
f"No files in {Config.REPLACE_FILES_DEFAULT_SOURCE} to import",
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_002_import_file_and_cancel(self):
|
|
||||||
"""Cancel file import"""
|
|
||||||
|
|
||||||
test_track_path = "testdata/isa.mp3"
|
|
||||||
shutil.copy(test_track_path, self.import_source)
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch("file_importer.PickMatch") as MockPickMatch,
|
|
||||||
patch("file_importer.show_OK") as mock_show_ok,
|
|
||||||
):
|
|
||||||
# Create a mock instance of PickMatch
|
|
||||||
mock_dialog_instance = MagicMock()
|
|
||||||
MockPickMatch.return_value = mock_dialog_instance
|
|
||||||
|
|
||||||
# Simulate the user clicking OK in the dialog
|
|
||||||
mock_dialog_instance.exec.return_value = QDialog.DialogCode.Rejected
|
|
||||||
mock_dialog_instance.selected_track_id = -1 # Simulated return value
|
|
||||||
|
|
||||||
self.widget.import_files_wrapper()
|
|
||||||
|
|
||||||
# Ensure PickMatch was instantiated correctly
|
|
||||||
MockPickMatch.assert_called_once_with(
|
|
||||||
new_track_description="I'm So Afraid (Fleetwood Mac)",
|
|
||||||
choices=[("Do not import", -1, ""), ("Import as new track", 0, "")],
|
|
||||||
default=1,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify exec() was called
|
|
||||||
mock_dialog_instance.exec.assert_called_once()
|
|
||||||
|
|
||||||
# Ensure selected_track_id was accessed after dialog.exec()
|
|
||||||
assert mock_dialog_instance.selected_track_id < 0
|
|
||||||
|
|
||||||
mock_show_ok.assert_called_once_with(
|
|
||||||
"File not imported",
|
|
||||||
"isa.mp3 will not be imported because you asked not to import this file",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_003_import_first_file(self):
|
|
||||||
"""Import file into empty directory"""
|
|
||||||
|
|
||||||
test_track_path = "testdata/isa.mp3"
|
|
||||||
shutil.copy(test_track_path, self.import_source)
|
|
||||||
|
|
||||||
with patch("file_importer.PickMatch") as MockPickMatch:
|
|
||||||
# Create a mock instance of PickMatch
|
|
||||||
mock_dialog_instance = MagicMock()
|
|
||||||
MockPickMatch.return_value = mock_dialog_instance
|
|
||||||
|
|
||||||
# Simulate the user clicking OK in the dialog
|
|
||||||
mock_dialog_instance.exec.return_value = QDialog.DialogCode.Accepted
|
|
||||||
mock_dialog_instance.selected_track_id = 0 # Simulated return value
|
|
||||||
|
|
||||||
self.widget.import_files_wrapper()
|
|
||||||
|
|
||||||
# Ensure PickMatch was instantiated correctly
|
|
||||||
MockPickMatch.assert_called_once_with(
|
|
||||||
new_track_description="I'm So Afraid (Fleetwood Mac)",
|
|
||||||
choices=[("Do not import", -1, ""), ("Import as new track", 0, "")],
|
|
||||||
default=1,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify exec() was called
|
|
||||||
mock_dialog_instance.exec.assert_called_once()
|
|
||||||
|
|
||||||
# Ensure selected_track_id was accessed after dialog.exec()
|
|
||||||
assert mock_dialog_instance.selected_track_id == 0
|
|
||||||
|
|
||||||
self.wait_for_workers()
|
|
||||||
|
|
||||||
# Check track was imported
|
|
||||||
with db.Session() as session:
|
|
||||||
tracks = Tracks.get_all(session)
|
|
||||||
assert len(tracks) == 1
|
|
||||||
track = tracks[0]
|
|
||||||
assert track.title == "I'm So Afraid"
|
|
||||||
assert track.artist == "Fleetwood Mac"
|
|
||||||
track_file = os.path.join(
|
|
||||||
self.musicstore, os.path.basename(test_track_path)
|
|
||||||
)
|
|
||||||
assert track.path == track_file
|
|
||||||
assert os.path.exists(track_file)
|
|
||||||
assert os.listdir(self.import_source) == []
|
|
||||||
|
|
||||||
def test_004_import_second_file(self):
|
|
||||||
"""Import a second file"""
|
|
||||||
|
|
||||||
test_track_path = "testdata/lovecats.mp3"
|
|
||||||
shutil.copy(test_track_path, self.import_source)
|
|
||||||
|
|
||||||
with patch("file_importer.PickMatch") as MockPickMatch:
|
|
||||||
# Create a mock instance of PickMatch
|
|
||||||
mock_dialog_instance = MagicMock()
|
|
||||||
MockPickMatch.return_value = mock_dialog_instance
|
|
||||||
|
|
||||||
# Simulate the user clicking OK in the dialog
|
|
||||||
mock_dialog_instance.exec.return_value = QDialog.DialogCode.Accepted
|
|
||||||
mock_dialog_instance.selected_track_id = 0 # Simulated return value
|
|
||||||
|
|
||||||
self.widget.import_files_wrapper()
|
|
||||||
|
|
||||||
# Ensure PickMatch was instantiated correctly
|
|
||||||
MockPickMatch.assert_called_once_with(
|
|
||||||
new_track_description="The Lovecats (The Cure)",
|
|
||||||
choices=[("Do not import", -1, ""), ("Import as new track", 0, "")],
|
|
||||||
default=1,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify exec() was called
|
|
||||||
mock_dialog_instance.exec.assert_called_once()
|
|
||||||
|
|
||||||
# Ensure selected_track_id was accessed after dialog.exec()
|
|
||||||
assert mock_dialog_instance.selected_track_id == 0
|
|
||||||
|
|
||||||
self.wait_for_workers()
|
|
||||||
|
|
||||||
# Check track was imported
|
|
||||||
with db.Session() as session:
|
|
||||||
tracks = Tracks.get_all(session)
|
|
||||||
assert len(tracks) == 2
|
|
||||||
track = tracks[1]
|
|
||||||
assert track.title == "The Lovecats"
|
|
||||||
assert track.artist == "The Cure"
|
|
||||||
track_file = os.path.join(
|
|
||||||
self.musicstore, os.path.basename(test_track_path)
|
|
||||||
)
|
|
||||||
assert track.path == track_file
|
|
||||||
assert os.path.exists(track_file)
|
|
||||||
assert os.listdir(self.import_source) == []
|
|
||||||
|
|
||||||
def test_005_replace_file(self):
|
|
||||||
"""Import the same file again and update existing track"""
|
|
||||||
|
|
||||||
test_track_path = "testdata/lovecats.mp3"
|
|
||||||
shutil.copy(test_track_path, self.import_source)
|
|
||||||
|
|
||||||
with patch("file_importer.PickMatch") as MockPickMatch:
|
|
||||||
# Create a mock instance of PickMatch
|
|
||||||
mock_dialog_instance = MagicMock()
|
|
||||||
MockPickMatch.return_value = mock_dialog_instance
|
|
||||||
|
|
||||||
# Simulate the user clicking OK in the dialog
|
|
||||||
mock_dialog_instance.exec.return_value = QDialog.DialogCode.Accepted
|
|
||||||
mock_dialog_instance.selected_track_id = 2 # Simulated return value
|
|
||||||
|
|
||||||
self.widget.import_files_wrapper()
|
|
||||||
|
|
||||||
# Ensure PickMatch was instantiated correctly
|
|
||||||
MockPickMatch.assert_called_once_with(
|
|
||||||
new_track_description="The Lovecats (The Cure)",
|
|
||||||
choices=[
|
|
||||||
("Do not import", -1, ""),
|
|
||||||
("Import as new track", 0, ""),
|
|
||||||
(
|
|
||||||
"The Lovecats (The Cure) (100%)",
|
|
||||||
2,
|
|
||||||
os.path.join(
|
|
||||||
self.musicstore, os.path.basename(test_track_path)
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
default=2,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify exec() was called
|
|
||||||
mock_dialog_instance.exec.assert_called_once()
|
|
||||||
|
|
||||||
self.wait_for_workers()
|
|
||||||
|
|
||||||
# Check track was imported
|
|
||||||
with db.Session() as session:
|
|
||||||
tracks = Tracks.get_all(session)
|
|
||||||
assert len(tracks) == 2
|
|
||||||
track = tracks[1]
|
|
||||||
assert track.title == "The Lovecats"
|
|
||||||
assert track.artist == "The Cure"
|
|
||||||
assert track.id == 2
|
|
||||||
track_file = os.path.join(
|
|
||||||
self.musicstore, os.path.basename(test_track_path)
|
|
||||||
)
|
|
||||||
assert track.path == track_file
|
|
||||||
assert os.path.exists(track_file)
|
|
||||||
assert os.listdir(self.import_source) == []
|
|
||||||
|
|
||||||
def test_006_import_file_no_tags(self) -> None:
|
|
||||||
"""Try to import untagged file"""
|
|
||||||
|
|
||||||
test_track_path = "testdata/lovecats.mp3"
|
|
||||||
test_filename = os.path.basename(test_track_path)
|
|
||||||
|
|
||||||
shutil.copy(test_track_path, self.import_source)
|
|
||||||
import_file = os.path.join(self.import_source, test_filename)
|
|
||||||
assert os.path.exists(import_file)
|
|
||||||
|
|
||||||
# Remove tags
|
|
||||||
src = MP3(import_file)
|
|
||||||
src.delete()
|
|
||||||
src.save()
|
|
||||||
|
|
||||||
with patch("file_importer.show_OK") as mock_show_ok:
|
|
||||||
self.widget.import_files_wrapper()
|
|
||||||
mock_show_ok.assert_called_once_with(
|
|
||||||
"File not imported",
|
|
||||||
f"{test_filename} will not be imported because of tag errors "
|
|
||||||
f"(Missing tags in {import_file})",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_007_import_unreadable_file(self) -> None:
|
|
||||||
"""Import unreadable file"""
|
|
||||||
|
|
||||||
test_track_path = "testdata/lovecats.mp3"
|
|
||||||
test_filename = os.path.basename(test_track_path)
|
|
||||||
|
|
||||||
shutil.copy(test_track_path, self.import_source)
|
|
||||||
import_file = os.path.join(self.import_source, test_filename)
|
|
||||||
assert os.path.exists(import_file)
|
|
||||||
|
|
||||||
# Make undreadable
|
|
||||||
os.chmod(import_file, 0)
|
|
||||||
|
|
||||||
with patch("file_importer.show_OK") as mock_show_ok:
|
|
||||||
self.widget.import_files_wrapper()
|
|
||||||
mock_show_ok.assert_called_once_with(
|
|
||||||
"File not imported",
|
|
||||||
f"{test_filename} will not be imported because {import_file} is unreadable",
|
|
||||||
)
|
|
||||||
|
|
||||||
# clean up
|
|
||||||
os.chmod(import_file, 0o777)
|
|
||||||
os.unlink(import_file)
|
|
||||||
|
|
||||||
def test_008_import_new_file_existing_destination(self) -> None:
|
|
||||||
"""Import duplicate file"""
|
|
||||||
|
|
||||||
test_track_path = "testdata/lovecats.mp3"
|
|
||||||
test_filename = os.path.basename(test_track_path)
|
|
||||||
new_destination = os.path.join(self.musicstore, "lc2.mp3")
|
|
||||||
|
|
||||||
shutil.copy(test_track_path, self.import_source)
|
|
||||||
import_file = os.path.join(self.import_source, test_filename)
|
|
||||||
assert os.path.exists(import_file)
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch("file_importer.PickMatch") as MockPickMatch,
|
|
||||||
patch.object(
|
|
||||||
QFileDialog, "getSaveFileName", return_value=(new_destination, "")
|
|
||||||
) as mock_file_dialog,
|
|
||||||
patch("file_importer.show_OK") as mock_show_ok,
|
|
||||||
):
|
|
||||||
mock_file_dialog.return_value = (
|
|
||||||
new_destination,
|
|
||||||
"",
|
|
||||||
) # Ensure mock correctly returns expected value
|
|
||||||
|
|
||||||
# Create a mock instance of PickMatch
|
|
||||||
mock_dialog_instance = MagicMock()
|
|
||||||
MockPickMatch.return_value = mock_dialog_instance
|
|
||||||
|
|
||||||
# Simulate the user clicking OK in the dialog
|
|
||||||
mock_dialog_instance.exec.return_value = QDialog.DialogCode.Accepted
|
|
||||||
mock_dialog_instance.selected_track_id = 0 # Simulated return value
|
|
||||||
|
|
||||||
self.widget.import_files_wrapper()
|
|
||||||
|
|
||||||
# Ensure PickMatch was instantiated correctly
|
|
||||||
MockPickMatch.assert_called_once_with(
|
|
||||||
new_track_description="The Lovecats (The Cure)",
|
|
||||||
choices=[
|
|
||||||
("Do not import", -1, ""),
|
|
||||||
("Import as new track", 0, ""),
|
|
||||||
(
|
|
||||||
"The Lovecats (The Cure) (100%)",
|
|
||||||
2,
|
|
||||||
os.path.join(
|
|
||||||
self.musicstore, os.path.basename(test_track_path)
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
default=2,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify exec() was called
|
|
||||||
mock_dialog_instance.exec.assert_called_once()
|
|
||||||
|
|
||||||
destination = os.path.join(self.musicstore, test_filename)
|
|
||||||
mock_show_ok.assert_called_once_with(
|
|
||||||
title="Desintation path exists",
|
|
||||||
msg=f"New import requested but default destination path ({destination}) "
|
|
||||||
"already exists. Click OK and choose where to save this track",
|
|
||||||
parent=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
self.wait_for_workers()
|
|
||||||
|
|
||||||
# Ensure QFileDialog was called and returned expected value
|
|
||||||
assert mock_file_dialog.called # Ensure the mock was used
|
|
||||||
result = mock_file_dialog()
|
|
||||||
assert result[0] == new_destination # Validate return value
|
|
||||||
|
|
||||||
# Check track was imported
|
|
||||||
with db.Session() as session:
|
|
||||||
tracks = Tracks.get_all(session)
|
|
||||||
assert len(tracks) == 3
|
|
||||||
track = tracks[2]
|
|
||||||
assert track.title == "The Lovecats"
|
|
||||||
assert track.artist == "The Cure"
|
|
||||||
assert track.id == 3
|
|
||||||
assert track.path == new_destination
|
|
||||||
assert os.path.exists(new_destination)
|
|
||||||
assert os.listdir(self.import_source) == []
|
|
||||||
|
|
||||||
# Remove file so as not to interfere with later tests
|
|
||||||
session.delete(track)
|
|
||||||
tracks = Tracks.get_all(session)
|
|
||||||
assert len(tracks) == 2
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
os.unlink(new_destination)
|
|
||||||
assert not os.path.exists(new_destination)
|
|
||||||
|
|
||||||
def test_009_import_similar_file(self) -> None:
|
|
||||||
"""Import file with similar, but different, title"""
|
|
||||||
|
|
||||||
test_track_path = "testdata/lovecats.mp3"
|
|
||||||
test_filename = os.path.basename(test_track_path)
|
|
||||||
|
|
||||||
shutil.copy(test_track_path, self.import_source)
|
|
||||||
import_file = os.path.join(self.import_source, test_filename)
|
|
||||||
assert os.path.exists(import_file)
|
|
||||||
|
|
||||||
# Change title tag
|
|
||||||
src = MP3(import_file)
|
|
||||||
src["TIT2"].text[0] += " xyz"
|
|
||||||
src.save()
|
|
||||||
|
|
||||||
with patch("file_importer.PickMatch") as MockPickMatch:
|
|
||||||
# Create a mock instance of PickMatch
|
|
||||||
mock_dialog_instance = MagicMock()
|
|
||||||
MockPickMatch.return_value = mock_dialog_instance
|
|
||||||
|
|
||||||
# Simulate the user clicking OK in the dialog
|
|
||||||
mock_dialog_instance.exec.return_value = QDialog.DialogCode.Accepted
|
|
||||||
mock_dialog_instance.selected_track_id = 2 # Simulated return value
|
|
||||||
|
|
||||||
self.widget.import_files_wrapper()
|
|
||||||
|
|
||||||
# Ensure PickMatch was instantiated correctly
|
|
||||||
MockPickMatch.assert_called_once_with(
|
|
||||||
new_track_description="The Lovecats xyz (The Cure)",
|
|
||||||
choices=[
|
|
||||||
("Do not import", -1, ""),
|
|
||||||
("Import as new track", 0, ""),
|
|
||||||
(
|
|
||||||
"The Lovecats (The Cure) (93%)",
|
|
||||||
2,
|
|
||||||
os.path.join(
|
|
||||||
self.musicstore, os.path.basename(test_track_path)
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
default=2,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify exec() was called
|
|
||||||
mock_dialog_instance.exec.assert_called_once()
|
|
||||||
|
|
||||||
self.wait_for_workers()
|
|
||||||
|
|
||||||
# Check track was imported
|
|
||||||
with db.Session() as session:
|
|
||||||
tracks = Tracks.get_all(session)
|
|
||||||
assert len(tracks) == 2
|
|
||||||
track = tracks[1]
|
|
||||||
assert track.title == "The Lovecats xyz"
|
|
||||||
assert track.artist == "The Cure"
|
|
||||||
assert track.id == 2
|
|
||||||
track_file = os.path.join(
|
|
||||||
self.musicstore, os.path.basename(test_track_path)
|
|
||||||
)
|
|
||||||
assert track.path == track_file
|
|
||||||
assert os.path.exists(track_file)
|
|
||||||
assert os.listdir(self.import_source) == []
|
|
||||||
@ -3,15 +3,19 @@ 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