Compare commits

...

18 Commits

Author SHA1 Message Date
Keith Edmunds
0ea12eb9d9 Clean up merge from dev 2025-03-30 12:05:41 +01:00
Keith Edmunds
75cc7a3f19 Merge changes from dev 2025-03-30 11:56:24 +01:00
Keith Edmunds
f64671d126 Improve function logging
Use @log_call decorator

Add 'checked' parameter to menu slots because PyQt6 will pass a
boolean 'checked' parameter even when the menu item can't be checked.

Remove superfluous logging calls.
2025-03-29 21:04:59 +00:00
Keith Edmunds
2bf1bc64e7 WIP: Unify function to move rows within/between playlists 2025-03-29 18:20:38 +00:00
Keith Edmunds
3c7fc20e5a Remove kae.py from git 2025-03-29 18:20:38 +00:00
Keith Edmunds
52d2269ece WIP: Move rows within and between playlists working 2025-03-29 18:20:38 +00:00
Keith Edmunds
3cd764c893 WIP: moving rows within playlist works 2025-03-29 18:20:38 +00:00
Keith Edmunds
65878b0b75 WIP: all tests for move rows within playlist working 2025-03-29 18:20:38 +00:00
Keith Edmunds
4e89d72a8f WIP: move within playlist tests working 2025-03-29 18:20:38 +00:00
Keith Edmunds
92ecb632b5 Report correct line for ApplicationError 2025-03-29 18:20:38 +00:00
Keith Edmunds
6296566c2c WIP: Can play tracks without errors 2025-03-29 18:20:38 +00:00
Keith Edmunds
7e5b170f5e Use @singleton decorator 2025-03-29 18:20:38 +00:00
Keith Edmunds
3db71a08ae WIP remove sessions, use reporistory 2025-03-29 18:20:38 +00:00
Keith Edmunds
7b0e2b2c6c WIP: playlists load, can't play track 2025-03-29 18:20:38 +00:00
Keith Edmunds
4265472d73 Keep track of selected rows in model 2025-03-29 18:20:38 +00:00
Keith Edmunds
c94cadf24f WIP: Use PlaylistRowDTO to isolate SQLAlchemy objects 2025-03-29 18:20:38 +00:00
Keith Edmunds
9720c11ecc Don't track kae.py in git 2025-03-29 18:20:13 +00:00
Keith Edmunds
ca4c490091 Add log_call decorator and issue 287 logging 2025-03-29 18:19:14 +00:00
19 changed files with 2534 additions and 1572 deletions

1
.gitignore vendored
View File

@ -14,3 +14,4 @@ StudioPlaylist.png
tmp/
.coverage
profile_output*
kae.py

View File

@ -1,7 +1,7 @@
# Standard library imports
from __future__ import annotations
from dataclasses import dataclass
import datetime as dt
from enum import auto, Enum
import functools
import threading
@ -91,31 +91,6 @@ class Filter:
duration_unit: str = "minutes"
@singleton
@dataclass
class MusicMusterSignals(QObject):
"""
Class for all MusicMuster signals. See:
- https://zetcode.com/gui/pyqt5/eventssignals/
- https://stackoverflow.com/questions/62654525/emit-a-signal-from-another-class-to-main-class
"""
begin_reset_model_signal = pyqtSignal(int)
enable_escape_signal = pyqtSignal(bool)
end_reset_model_signal = pyqtSignal(int)
next_track_changed_signal = pyqtSignal()
resize_rows_signal = pyqtSignal(int)
search_songfacts_signal = pyqtSignal(str)
search_wikipedia_signal = pyqtSignal(str)
show_warning_signal = pyqtSignal(str, str)
span_cells_signal = pyqtSignal(int, int, int, int, int)
status_message_signal = pyqtSignal(str, int)
track_ended_signal = pyqtSignal()
def __post_init__(self):
super().__init__()
class PlaylistStyle(QProxyStyle):
def drawPrimitive(self, element, option, painter, widget=None):
"""
@ -149,6 +124,89 @@ class Tags(NamedTuple):
duration: int = 0
@dataclass
class PlaylistDTO:
name: str
playlist_id: int
favourite: bool = False
is_template: bool = False
open: bool = False
@dataclass
class TrackDTO:
track_id: int
artist: str
bitrate: int
duration: int
fade_at: int
intro: int | None
path: str
silence_at: int
start_gap: int
title: str
lastplayed: dt.datetime | None
@dataclass
class PlaylistRowDTO(TrackDTO):
note: str
played: bool
playlist_id: int
playlistrow_id: int
row_number: int
class TrackInfo(NamedTuple):
track_id: int
row_number: int
# Classes for signals
@dataclass
class InsertRows:
playlist_id: int
from_row: int
to_row: int
@dataclass
class InsertTrack:
playlist_id: int
track_id: int | None
note: str
@singleton
@dataclass
class MusicMusterSignals(QObject):
"""
Class for all MusicMuster signals. See:
- https://zetcode.com/gui/pyqt5/eventssignals/
- https://stackoverflow.com/questions/62654525/emit-a-signal-from-another-class-to-main-class
"""
begin_reset_model_signal = pyqtSignal(int)
enable_escape_signal = pyqtSignal(bool)
end_reset_model_signal = pyqtSignal(int)
next_track_changed_signal = pyqtSignal()
resize_rows_signal = pyqtSignal(int)
search_songfacts_signal = pyqtSignal(str)
search_wikipedia_signal = pyqtSignal(str)
show_warning_signal = pyqtSignal(str, str)
signal_add_track_to_header = pyqtSignal(int, int)
signal_begin_insert_rows = pyqtSignal(InsertRows)
signal_end_insert_rows = pyqtSignal(int)
signal_insert_track = pyqtSignal(InsertTrack)
signal_playlist_selected_rows = pyqtSignal(int, list)
signal_set_next_row = pyqtSignal(int)
# signal_set_next_track takes a PlaylistRow as an argument. We can't
# specify that here as it requires us to import PlaylistRow from
# playlistrow.py, which itself imports MusicMusterSignals
signal_set_next_track = pyqtSignal(object)
span_cells_signal = pyqtSignal(int, int, int, int, int)
status_message_signal = pyqtSignal(str, int)
track_ended_signal = pyqtSignal()
def __post_init__(self):
super().__init__()

View File

@ -112,6 +112,8 @@ class Config(object):
PLAYLIST_ICON_CURRENT = ":/icons/green-circle.png"
PLAYLIST_ICON_NEXT = ":/icons/yellow-circle.png"
PLAYLIST_ICON_TEMPLATE = ":/icons/redstar.png"
PLAYLIST_PENDING_MOVE = -1
PLAYLIST_FAILED_MOVE = -2
PREVIEW_ADVANCE_MS = 5000
PREVIEW_BACK_MS = 5000
PREVIEW_END_BUFFER_MS = 1000

View File

@ -9,12 +9,22 @@ from PyQt6.QtWidgets import (
QListWidgetItem,
QMainWindow,
)
from PyQt6.QtWidgets import (
QDialog,
QHBoxLayout,
QLabel,
QLineEdit,
QListWidget,
QListWidgetItem,
QPushButton,
QVBoxLayout,
)
# Third party imports
from sqlalchemy.orm.session import Session
# App imports
from classes import MusicMusterSignals
from classes import ApplicationError, InsertTrack, MusicMusterSignals
from helpers import (
ask_yes_no,
get_relative_date,
@ -23,209 +33,153 @@ from helpers import (
from log import log
from models import Settings, Tracks
from playlistmodel import PlaylistModel
import repository
from ui import dlg_TrackSelect_ui
class TrackSelectDialog(QDialog):
"""Select track from database"""
class TrackInsertDialog(QDialog):
def __init__(
self,
parent: QMainWindow,
session: Session,
new_row_number: int,
base_model: PlaylistModel,
playlist_id: int,
add_to_header: Optional[bool] = False,
*args: Qt.WindowType,
**kwargs: Qt.WindowType,
) -> None:
"""
Subclassed QDialog to manage track selection
"""
super().__init__(parent, *args, **kwargs)
self.session = session
self.new_row_number = new_row_number
self.base_model = base_model
super().__init__(parent)
self.playlist_id = playlist_id
self.add_to_header = add_to_header
self.ui = dlg_TrackSelect_ui.Ui_Dialog()
self.ui.setupUi(self)
self.ui.btnAdd.clicked.connect(self.add_selected)
self.ui.btnAddClose.clicked.connect(self.add_selected_and_close)
self.ui.btnClose.clicked.connect(self.close)
self.ui.matchList.itemDoubleClicked.connect(self.add_selected)
self.ui.matchList.itemSelectionChanged.connect(self.selection_changed)
self.ui.radioTitle.toggled.connect(self.title_artist_toggle)
self.ui.searchString.textEdited.connect(self.chars_typed)
self.track: Optional[Tracks] = None
self.signals = MusicMusterSignals()
self.setWindowTitle("Insert Track")
record = Settings.get_setting(self.session, "dbdialog_width")
width = record.f_int or 800
record = Settings.get_setting(self.session, "dbdialog_height")
height = record.f_int or 600
self.resize(width, height)
# Title input on one line
self.title_label = QLabel("Title:")
self.title_edit = QLineEdit()
self.title_edit.textChanged.connect(self.update_list)
if add_to_header:
self.ui.lblNote.setVisible(False)
self.ui.txtNote.setVisible(False)
title_layout = QHBoxLayout()
title_layout.addWidget(self.title_label)
title_layout.addWidget(self.title_edit)
def add_selected(self) -> None:
"""Handle Add button"""
# Track list
self.track_list = QListWidget()
self.track_list.itemDoubleClicked.connect(self.add_clicked)
self.track_list.itemSelectionChanged.connect(self.selection_changed)
track = None
# Note input on one line
self.note_label = QLabel("Note:")
self.note_edit = QLineEdit()
if self.ui.matchList.selectedItems():
item = self.ui.matchList.currentItem()
if item:
track = item.data(Qt.ItemDataRole.UserRole)
note_layout = QHBoxLayout()
note_layout.addWidget(self.note_label)
note_layout.addWidget(self.note_edit)
note = self.ui.txtNote.text()
# Track path
self.path = QLabel()
path_layout = QHBoxLayout()
path_layout.addWidget(self.path)
if not (track or note):
# Buttons
self.add_btn = QPushButton("Add")
self.add_close_btn = QPushButton("Add and close")
self.close_btn = QPushButton("Close")
self.add_btn.clicked.connect(self.add_clicked)
self.add_close_btn.clicked.connect(self.add_and_close_clicked)
self.close_btn.clicked.connect(self.close)
btn_layout = QHBoxLayout()
btn_layout.addWidget(self.add_btn)
btn_layout.addWidget(self.add_close_btn)
btn_layout.addWidget(self.close_btn)
# Main layout
layout = QVBoxLayout()
layout.addLayout(title_layout)
layout.addWidget(self.track_list)
layout.addLayout(note_layout)
layout.addLayout(path_layout)
layout.addLayout(btn_layout)
self.setLayout(layout)
self.resize(800, 600)
# TODO
# record = Settings.get_setting(self.session, "dbdialog_width")
# width = record.f_int or 800
# record = Settings.get_setting(self.session, "dbdialog_height")
# height = record.f_int or 600
# self.resize(width, height)
def update_list(self, text: str) -> None:
self.track_list.clear()
if text.strip() == "":
# Do not search or populate list if input is empty
return
track_id = None
if track:
track_id = track.id
if text.startswith("a/") and len(text) > 2:
self.tracks = repository.tracks_like_artist(text[2:])
else:
self.tracks = repository.tracks_like_title(text)
if note and not track_id:
self.base_model.insert_row(self.new_row_number, track_id, note)
self.ui.txtNote.clear()
self.new_row_number += 1
for track in self.tracks:
duration_str = ms_to_mmss(track.duration)
last_played_str = get_relative_date(track.lastplayed)
item_str = (
f"{track.title} - {track.artist} [{duration_str}] {last_played_str}"
)
item = QListWidgetItem(item_str)
item.setData(Qt.ItemDataRole.UserRole, track.track_id)
self.track_list.addItem(item)
def get_selected_track_id(self) -> int | None:
selected_items = self.track_list.selectedItems()
if selected_items:
return selected_items[0].data(Qt.ItemDataRole.UserRole)
return None
def add_clicked(self):
track_id = self.get_selected_track_id()
note_text = self.note_edit.text()
if track_id is None and not note_text:
return
self.ui.txtNote.clear()
self.select_searchtext()
insert_track_data = InsertTrack(self.playlist_id, track_id, note_text)
self.signals.signal_insert_track.emit(insert_track_data)
if track_id is None:
log.error("track_id is None and should not be")
return
# Check whether track is already in playlist
move_existing = False
existing_prd = self.base_model.is_track_in_playlist(track_id)
if existing_prd is not None:
if ask_yes_no(
"Duplicate row",
"Track already in playlist. " "Move to new location?",
default_yes=True,
):
move_existing = True
self.title_edit.clear()
self.note_edit.clear()
self.track_list.clear()
self.title_edit.setFocus()
if self.add_to_header:
if move_existing and existing_prd: # "and existing_prd" for mypy's benefit
self.base_model.move_track_to_header(
self.new_row_number, existing_prd, note
)
else:
self.base_model.add_track_to_header(self.new_row_number, track_id)
# Close dialog - we can only add one track to a header
self.accept()
else:
# Adding a new track row
if move_existing and existing_prd: # "and existing_prd" for mypy's benefit
self.base_model.move_track_add_note(
self.new_row_number, existing_prd, note
)
else:
self.base_model.insert_row(self.new_row_number, track_id, note)
self.new_row_number += 1
def add_selected_and_close(self) -> None:
"""Handle Add and Close button"""
self.add_selected()
def add_and_close_clicked(self):
track_id = self.get_selected_track_id()
if track_id is not None:
note_text = self.note_edit.text()
insert_track_data = InsertTrack(
playlist_id=self.playlist_id, track_id=self.track_id, note=self.note
)
insert_track_data = InsertTrack(self.playlist_id, track_id, note_text)
self.signals.signal_insert_track.emit(insert_track_data)
self.accept()
def chars_typed(self, s: str) -> None:
"""Handle text typed in search box"""
self.ui.matchList.clear()
if len(s) > 0:
if s.startswith("a/") and len(s) > 2:
matches = Tracks.search_artists(self.session, "%" + s[2:])
elif self.ui.radioTitle.isChecked():
matches = Tracks.search_titles(self.session, "%" + s)
else:
matches = Tracks.search_artists(self.session, "%" + s)
if matches:
for track in matches:
last_played = None
last_playdate = max(
track.playdates, key=lambda p: p.lastplayed, default=None
)
if last_playdate:
last_played = last_playdate.lastplayed
t = QListWidgetItem()
track_text = (
f"{track.title} - {track.artist} "
f"[{ms_to_mmss(track.duration)}] "
f"({get_relative_date(last_played)})"
)
t.setText(track_text)
t.setData(Qt.ItemDataRole.UserRole, track)
self.ui.matchList.addItem(t)
def closeEvent(self, event: Optional[QEvent]) -> None:
"""
Override close and save dialog coordinates
"""
if not event:
return
record = Settings.get_setting(self.session, "dbdialog_height")
record.f_int = self.height()
record = Settings.get_setting(self.session, "dbdialog_width")
record.f_int = self.width()
self.session.commit()
event.accept()
def keyPressEvent(self, event: QKeyEvent | None) -> None:
"""
Clear selection on ESC if there is one
"""
if event and event.key() == Qt.Key.Key_Escape:
if self.ui.matchList.selectedItems():
self.ui.matchList.clearSelection()
return
super(TrackSelectDialog, self).keyPressEvent(event)
def select_searchtext(self) -> None:
"""Select the searchbox"""
self.ui.searchString.selectAll()
self.ui.searchString.setFocus()
def selection_changed(self) -> None:
"""Display selected track path in dialog box"""
if not self.ui.matchList.selectedItems():
self.path.setText("")
track_id = self.get_selected_track_id()
if track_id is None:
return
item = self.ui.matchList.currentItem()
track = item.data(Qt.ItemDataRole.UserRole)
last_playdate = max(track.playdates, key=lambda p: p.lastplayed, default=None)
if last_playdate:
last_played = last_playdate.lastplayed
else:
last_played = None
path_text = f"{track.path} ({get_relative_date(last_played)})"
tracklist = [t for t in self.tracks if t.track_id == track_id]
if not tracklist:
return
if len(tracklist) > 1:
raise ApplicationError("More than one track returned")
track = tracklist[0]
self.ui.dbPath.setText(path_text)
def title_artist_toggle(self) -> None:
"""
Handle switching between searching for artists and searching for
titles
"""
# Logic is handled already in chars_typed(), so just call that.
self.chars_typed(self.ui.searchString.text())
self.path.setText(track.path)

View File

@ -32,6 +32,7 @@ from classes import (
MusicMusterSignals,
singleton,
Tags,
TrackDTO,
)
from config import Config
from helpers import (
@ -40,8 +41,7 @@ from helpers import (
show_OK,
)
from log import log
from models import db, Tracks
from music_manager import track_sequence
from playlistrow import TrackSequence
from playlistmodel import PlaylistModel
import helpers
@ -104,16 +104,14 @@ class FileImporter:
# variable or an instance variable are effectively the same thing.
workers: dict[str, DoTrackImport] = {}
def __init__(
self, base_model: PlaylistModel, row_number: Optional[int] = None
) -> None:
def __init__(self, base_model: PlaylistModel, row_number: int) -> None:
"""
Initialise the FileImporter singleton instance.
"""
log.debug(f"FileImporter.__init__({base_model=}, {row_number=})")
# Create ModelData
if not row_number:
row_number = base_model.rowCount()
self.model_data = ThreadData(base_model=base_model, row_number=row_number)
# Data structure to track files to import
@ -122,13 +120,7 @@ class FileImporter:
# Get signals
self.signals = MusicMusterSignals()
def _get_existing_tracks(self) -> Sequence[Tracks]:
"""
Return a list of all existing Tracks
"""
with db.Session() as session:
return Tracks.get_all(session)
self.existing_tracks: list[TrackDTO] = []
def start(self) -> None:
"""
@ -148,7 +140,7 @@ class FileImporter:
# Refresh list of existing tracks as they may have been updated
# by previous imports
self.existing_tracks = self._get_existing_tracks()
self.existing_tracks = repository.get_all_tracks()
for infile in [
os.path.join(Config.REPLACE_FILES_DEFAULT_SOURCE, f)
@ -701,6 +693,7 @@ class PickMatch(QDialog):
self.setWindowTitle("New or replace")
layout = QVBoxLayout()
track_sequence = TrackSequence()
# Add instructions
instructions = (

View File

@ -20,7 +20,7 @@ from pydub.utils import mediainfo
from tinytag import TinyTag, TinyTagException # type: ignore
# App imports
from classes import AudioMetadata, ApplicationError, Tags
from classes import AudioMetadata, ApplicationError, Tags, TrackDTO
from config import Config
from log import log
from models import Tracks
@ -168,7 +168,7 @@ def get_name(prompt: str, default: str = "") -> str | None:
def get_relative_date(
past_date: Optional[dt.datetime], reference_date: Optional[dt.datetime] = None
past_date: Optional[dt.datetime], now: Optional[dt.datetime] = None
) -> str:
"""
Return how long before reference_date past_date is as string.
@ -182,31 +182,33 @@ def get_relative_date(
if not past_date or past_date == Config.EPOCH:
return "Never"
if not reference_date:
reference_date = dt.datetime.now()
if not now:
now = dt.datetime.now()
# Check parameters
if past_date > reference_date:
return "get_relative_date() past_date is after relative_date"
if past_date > now:
raise ApplicationError("get_relative_date() past_date is after relative_date")
days: int
days_str: str
weeks: int
weeks_str: str
delta = now - past_date
days = delta.days
weeks, days = divmod((reference_date.date() - past_date.date()).days, 7)
if weeks == days == 0:
# Same day so return time instead
return Config.LAST_PLAYED_TODAY_STRING + " " + past_date.strftime("%H:%M")
if weeks == 1:
weeks_str = "week"
else:
weeks_str = "weeks"
if days == 1:
days_str = "day"
else:
days_str = "days"
return f"{weeks} {weeks_str}, {days} {days_str}"
if days == 0:
return "(Today)"
elif days == 1:
return "(Yesterday)"
years, days_remain = divmod(days, 365)
months, days_final = divmod(days_remain, 30)
parts = []
if years:
parts.append(f"{years}y")
if months:
parts.append(f"{months}m")
if days_final:
parts.append(f"{days_final}d")
formatted = " ".join(parts)
return f"({formatted} ago)"
def get_tags(path: str) -> Tags:
@ -264,39 +266,15 @@ def leading_silence(
return min(trim_ms, len(audio_segment))
def ms_to_mmss(
ms: Optional[int],
decimals: int = 0,
negative: bool = False,
none: Optional[str] = None,
) -> str:
def ms_to_mmss(ms: int | None, none: str = "-") -> str:
"""Convert milliseconds to mm:ss"""
minutes: int
remainder: int
seconds: float
if ms is None:
return none
if not ms:
if none:
return none
else:
return "-"
sign = ""
if ms < 0:
if negative:
sign = "-"
else:
ms = 0
minutes, seconds = divmod(ms // 1000, 60)
minutes, remainder = divmod(ms, 60 * 1000)
seconds = remainder / 1000
# if seconds >= 59.5, it will be represented as 60, which looks odd.
# So, fake it under those circumstances
if seconds >= 59.5:
seconds = 59.0
return f"{sign}{minutes:.0f}:{seconds:02.{decimals}f}"
return f"{minutes}:{seconds:02d}"
def normalise_track(path: str) -> None:

View File

@ -1,6 +1,7 @@
#!/usr/bin/env python3
# Standard library imports
from collections import defaultdict
from functools import wraps
import logging
import logging.config
import logging.handlers
@ -79,9 +80,22 @@ log = logging.getLogger(Config.LOG_NAME)
def handle_exception(exc_type, exc_value, exc_traceback):
error = str(exc_value)
"""
Inform user of exception
"""
# Navigate to the inner stack frame
tb = exc_traceback
while tb.tb_next:
tb = tb.tb_next
fname = os.path.basename(tb.tb_frame.f_code.co_filename)
lineno = tb.tb_lineno
msg = f"ApplicationError: {exc_value}\nat {fname}:{lineno}"
logmsg = f"ApplicationError: {exc_value} at {fname}:{lineno}"
if issubclass(exc_type, ApplicationError):
log.error(error)
log.error(logmsg)
else:
# Handle unexpected errors (log and display)
error_msg = "".join(traceback.format_exception(exc_type, exc_value, exc_traceback))
@ -104,8 +118,33 @@ def handle_exception(exc_type, exc_value, exc_traceback):
)
if QApplication.instance() is not None:
fname = os.path.split(exc_traceback.tb_frame.f_code.co_filename)[1]
msg = f"ApplicationError: {error}\nat {fname}:{exc_traceback.tb_lineno}"
QMessageBox.critical(None, "Application Error", msg)
def truncate_large(obj, limit=5):
"""Helper to truncate large lists or other iterables."""
if isinstance(obj, (list, tuple, set)):
if len(obj) > limit:
return f"{type(obj).__name__}(len={len(obj)}, items={list(obj)[:limit]}...)"
return repr(obj)
def log_call(func):
@wraps(func)
def wrapper(*args, **kwargs):
args_repr = [truncate_large(a) for a in args]
kwargs_repr = [f"{k}={truncate_large(v)}" for k, v in kwargs.items()]
params_repr = ", ".join(args_repr + kwargs_repr)
log.debug(f"call {func.__name__}({params_repr})", stacklevel=2)
try:
result = func(*args, **kwargs)
log.debug(f"return {func.__name__}: {truncate_large(result)}", stacklevel=2)
return result
except Exception as e:
log.debug(f"exception in {func.__name__}: {e}", stacklevel=2)
raise
return wrapper
sys.excepthook = handle_exception

View File

@ -241,7 +241,9 @@ class Playlists(dbtables.PlaylistsTable):
"""
session.execute(
update(Playlists).where((Playlists.id.in_(playlist_ids))).values(tab=None)
update(Playlists)
.where(Playlists.id.in_(playlist_ids))
.values(tab=None)
)
def close(self, session: Session) -> None:
@ -395,6 +397,7 @@ class PlaylistRows(dbtables.PlaylistRowsTable):
given playlist_id and row
"""
# TODO: use selectinload?
stmt = (
select(PlaylistRows)
.options(joinedload(cls.track))
@ -435,24 +438,6 @@ class PlaylistRows(dbtables.PlaylistRowsTable):
)
)
@staticmethod
def fixup_rownumbers(session: Session, playlist_id: int) -> None:
"""
Ensure the row numbers for passed playlist have no gaps
"""
plrs = session.scalars(
select(PlaylistRows)
.where(PlaylistRows.playlist_id == playlist_id)
.order_by(PlaylistRows.row_number)
).all()
for i, plr in enumerate(plrs):
plr.row_number = i
# Ensure new row numbers are available to the caller
session.commit()
@classmethod
def plrids_to_plrs(
cls, session: Session, playlist_id: int, plr_ids: list[int]

View File

@ -3,32 +3,23 @@ from __future__ import annotations
import datetime as dt
from time import sleep
from typing import Optional
# Third party imports
# import line_profiler
import numpy as np
import pyqtgraph as pg # type: ignore
from sqlalchemy.orm.session import Session
import vlc # type: ignore
# PyQt imports
from PyQt6.QtCore import (
pyqtSignal,
QObject,
QThread,
)
from pyqtgraph import PlotWidget
from pyqtgraph.graphicsItems.PlotDataItem import PlotDataItem # type: ignore
from pyqtgraph.graphicsItems.LinearRegionItem import LinearRegionItem # type: ignore
# App imports
from classes import ApplicationError, MusicMusterSignals
from classes import singleton
from config import Config
import helpers
from log import log
from models import PlaylistRows
from vlcmanager import VLCManager
# Define the VLC callback function type
# import ctypes
@ -63,106 +54,6 @@ from vlcmanager import VLCManager
# libc.vsnprintf.restype = ctypes.c_int
class _AddFadeCurve(QObject):
"""
Initialising a fade curve introduces a noticeable delay so carry out in
a thread.
"""
finished = pyqtSignal()
def __init__(
self,
rat: RowAndTrack,
track_path: str,
track_fade_at: int,
track_silence_at: int,
) -> None:
super().__init__()
self.rat = rat
self.track_path = track_path
self.track_fade_at = track_fade_at
self.track_silence_at = track_silence_at
def run(self) -> None:
"""
Create fade curve and add to PlaylistTrack object
"""
fc = _FadeCurve(self.track_path, self.track_fade_at, self.track_silence_at)
if not fc:
log.error(f"Failed to create FadeCurve for {self.track_path=}")
else:
self.rat.fade_graph = fc
self.finished.emit()
class _FadeCurve:
GraphWidget: Optional[PlotWidget] = None
def __init__(
self, track_path: str, track_fade_at: int, track_silence_at: int
) -> None:
"""
Set up fade graph array
"""
audio = helpers.get_audio_segment(track_path)
if not audio:
log.error(f"FadeCurve: could not get audio for {track_path=}")
return None
# Start point of curve is Config.FADE_CURVE_MS_BEFORE_FADE
# milliseconds before fade starts to silence
self.start_ms: int = max(
0, track_fade_at - Config.FADE_CURVE_MS_BEFORE_FADE - 1
)
self.end_ms: int = track_silence_at
audio_segment = audio[self.start_ms : self.end_ms]
self.graph_array = np.array(audio_segment.get_array_of_samples())
# Calculate the factor to map milliseconds of track to array
self.ms_to_array_factor = len(self.graph_array) / (self.end_ms - self.start_ms)
self.curve: Optional[PlotDataItem] = None
self.region: Optional[LinearRegionItem] = None
def clear(self) -> None:
"""Clear the current graph"""
if self.GraphWidget:
self.GraphWidget.clear()
def plot(self) -> None:
if self.GraphWidget:
self.curve = self.GraphWidget.plot(self.graph_array)
if self.curve:
self.curve.setPen(Config.FADE_CURVE_FOREGROUND)
else:
log.debug("_FadeCurve.plot: no curve")
else:
log.debug("_FadeCurve.plot: no GraphWidget")
def tick(self, play_time: int) -> None:
"""Update volume fade curve"""
if not self.GraphWidget:
return
ms_of_graph = play_time - self.start_ms
if ms_of_graph < 0:
return
if self.region is None:
# Create the region now that we're into fade
self.region = pg.LinearRegionItem([0, 0], bounds=[0, len(self.graph_array)])
self.GraphWidget.addItem(self.region)
# Update region position
if self.region:
self.region.setRegion([0, ms_of_graph * self.ms_to_array_factor])
class _FadeTrack(QThread):
finished = pyqtSignal()
@ -196,21 +87,32 @@ class _FadeTrack(QThread):
self.finished.emit()
# TODO can we move this into the _Music class?
vlc_instance = VLCManager().vlc_instance
@singleton
class VLCManager:
"""
Singleton class to ensure we only ever have one vlc Instance
"""
def __init__(self) -> None:
self.vlc_instance = vlc.Instance()
def get_instance(self) -> vlc.Instance:
return self.vlc_instance
class _Music:
class Music:
"""
Manage the playing of music tracks
"""
def __init__(self, name: str) -> None:
vlc_instance.set_user_agent(name, name)
self.player: Optional[vlc.MediaPlayer] = None
self.name = name
vlc_manager = VLCManager()
self.vlc_instance = vlc_manager.get_instance()
self.vlc_instance.set_user_agent(name, name)
self.player: vlc.MediaPlayer | None = None
self.max_volume: int = Config.VLC_VOLUME_DEFAULT
self.start_dt: Optional[dt.datetime] = None
self.start_dt: dt.datetime | None = None
# Set up logging
# self._set_vlc_log()
@ -238,27 +140,6 @@ class _Music:
# except Exception as e:
# log.error(f"Failed to set up VLC logging: {e}")
def adjust_by_ms(self, ms: int) -> None:
"""Move player position by ms milliseconds"""
if not self.player:
return
elapsed_ms = self.get_playtime()
position = self.get_position()
if not position:
position = 0.0
new_position = max(0.0, position + ((position * ms) / elapsed_ms))
self.set_position(new_position)
# Adjus start time so elapsed time calculations are correct
if new_position == 0:
self.start_dt = dt.datetime.now()
else:
if self.start_dt:
self.start_dt -= dt.timedelta(milliseconds=ms)
else:
self.start_dt = dt.datetime.now() - dt.timedelta(milliseconds=ms)
def fade(self, fade_seconds: int) -> None:
"""
Fade the currently playing track.
@ -292,11 +173,11 @@ class _Music:
elapsed_seconds = (now - self.start_dt).total_seconds()
return int(elapsed_seconds * 1000)
def get_position(self) -> Optional[float]:
def get_position(self) -> float:
"""Return current position"""
if not self.player:
return None
return 0.0
return self.player.get_position()
def is_playing(self) -> bool:
@ -321,7 +202,7 @@ class _Music:
self,
path: str,
start_time: dt.datetime,
position: Optional[float] = None,
position: float | None = None,
) -> None:
"""
Start playing the track at path.
@ -338,7 +219,7 @@ class _Music:
log.error(f"play({path}): path not readable")
return None
self.player = vlc.MediaPlayer(vlc_instance, path)
self.player = vlc.MediaPlayer(self.vlc_instance, path)
if self.player is None:
log.error(f"_Music:play: failed to create MediaPlayer ({path=})")
helpers.show_warning(
@ -353,21 +234,6 @@ class _Music:
self.player.set_position(position)
self.start_dt = start_time
# For as-yet unknown reasons. sometimes the volume gets
# reset to zero within 200mS or so of starting play. This
# only happened since moving to Debian 12, which uses
# Pipewire for sound (which may be irrelevant).
# It has been known for the volume to need correcting more
# than once in the first 200mS.
# Update August 2024: This no longer seems to be an issue
# for _ in range(3):
# if self.player:
# volume = self.player.audio_get_volume()
# if volume < Config.VLC_VOLUME_DEFAULT:
# self.set_volume(Config.VLC_VOLUME_DEFAULT)
# log.error(f"Reset from {volume=}")
# sleep(0.1)
def set_position(self, position: float) -> None:
"""
Set player position
@ -377,7 +243,7 @@ class _Music:
self.player.set_position(position)
def set_volume(
self, volume: Optional[int] = None, set_default: bool = True
self, volume: int | None = None, set_default: bool = True
) -> None:
"""Set maximum volume used for player"""
@ -417,333 +283,3 @@ class _Music:
self.player.stop()
self.player.release()
self.player = None
class RowAndTrack:
"""
Object to manage playlist rows and tracks.
"""
def __init__(self, playlist_row: PlaylistRows) -> None:
"""
Initialises data structure.
The passed PlaylistRows object will include a Tracks object if this
row has a track.
"""
# Collect playlistrow data
self.note = playlist_row.note
self.played = playlist_row.played
self.playlist_id = playlist_row.playlist_id
self.playlistrow_id = playlist_row.id
self.row_number = playlist_row.row_number
self.track_id = playlist_row.track_id
# Playlist display data
self.row_fg: Optional[str] = None
self.row_bg: Optional[str] = None
self.note_fg: Optional[str] = None
self.note_bg: Optional[str] = None
# Collect track data if there's a track
if playlist_row.track_id:
self.artist = playlist_row.track.artist
self.bitrate = playlist_row.track.bitrate
self.duration = playlist_row.track.duration
self.fade_at = playlist_row.track.fade_at
self.intro = playlist_row.track.intro
if playlist_row.track.playdates:
self.lastplayed = max(
[a.lastplayed for a in playlist_row.track.playdates]
)
else:
self.lastplayed = Config.EPOCH
self.path = playlist_row.track.path
self.silence_at = playlist_row.track.silence_at
self.start_gap = playlist_row.track.start_gap
self.title = playlist_row.track.title
else:
self.artist = ""
self.bitrate = 0
self.duration = 0
self.fade_at = 0
self.intro = None
self.lastplayed = Config.EPOCH
self.path = ""
self.silence_at = 0
self.start_gap = 0
self.title = ""
# Track playing data
self.end_of_track_signalled: bool = False
self.end_time: Optional[dt.datetime] = None
self.fade_graph: Optional[_FadeCurve] = None
self.fade_graph_start_updates: Optional[dt.datetime] = None
self.resume_marker: Optional[float] = 0.0
self.forecast_end_time: Optional[dt.datetime] = None
self.forecast_start_time: Optional[dt.datetime] = None
self.start_time: Optional[dt.datetime] = None
# Other object initialisation
self.music = _Music(name=Config.VLC_MAIN_PLAYER_NAME)
self.signals = MusicMusterSignals()
def __repr__(self) -> str:
return (
f"<RowAndTrack(playlist_id={self.playlist_id}, "
f"row_number={self.row_number}, "
f"playlistrow_id={self.playlistrow_id}, "
f"note={self.note}, track_id={self.track_id}>"
)
def check_for_end_of_track(self) -> None:
"""
Check whether track has ended. If so, emit track_ended_signal
"""
if self.start_time is None:
return
if self.end_of_track_signalled:
return
if self.music.is_playing():
return
self.start_time = None
if self.fade_graph:
self.fade_graph.clear()
# Ensure that player is released
self.music.fade(0)
self.signals.track_ended_signal.emit()
self.end_of_track_signalled = True
def create_fade_graph(self) -> None:
"""
Initialise and add FadeCurve in a thread as it's slow
"""
self.fadecurve_thread = QThread()
self.worker = _AddFadeCurve(
self,
track_path=self.path,
track_fade_at=self.fade_at,
track_silence_at=self.silence_at,
)
self.worker.moveToThread(self.fadecurve_thread)
self.fadecurve_thread.started.connect(self.worker.run)
self.worker.finished.connect(self.fadecurve_thread.quit)
self.worker.finished.connect(self.worker.deleteLater)
self.fadecurve_thread.finished.connect(self.fadecurve_thread.deleteLater)
self.fadecurve_thread.start()
def drop3db(self, enable: bool) -> None:
"""
If enable is true, drop output by 3db else restore to full volume
"""
if enable:
self.music.set_volume(volume=Config.VLC_VOLUME_DROP3db, set_default=False)
else:
self.music.set_volume(volume=Config.VLC_VOLUME_DEFAULT, set_default=False)
def fade(self, fade_seconds: int = Config.FADEOUT_SECONDS) -> None:
"""Fade music"""
self.resume_marker = self.music.get_position()
self.music.fade(fade_seconds)
self.signals.track_ended_signal.emit()
def is_playing(self) -> bool:
"""
Return True if we're currently playing else False
"""
if self.start_time is None:
return False
return self.music.is_playing()
def move_back(self, ms: int = Config.PREVIEW_BACK_MS) -> None:
"""
Rewind player by ms milliseconds
"""
self.music.adjust_by_ms(ms * -1)
def move_forward(self, ms: int = Config.PREVIEW_ADVANCE_MS) -> None:
"""
Rewind player by ms milliseconds
"""
self.music.adjust_by_ms(ms)
def play(self, position: Optional[float] = None) -> None:
"""Play track"""
now = dt.datetime.now()
self.start_time = now
# Initialise player
self.music.play(self.path, start_time=now, position=position)
self.end_time = now + dt.timedelta(milliseconds=self.duration)
# Calculate time fade_graph should start updating
if self.fade_at:
update_graph_at_ms = max(
0, self.fade_at - Config.FADE_CURVE_MS_BEFORE_FADE - 1
)
self.fade_graph_start_updates = now + dt.timedelta(
milliseconds=update_graph_at_ms
)
def restart(self) -> None:
"""
Restart player
"""
self.music.adjust_by_ms(self.time_playing() * -1)
def set_forecast_start_time(
self, modified_rows: list[int], start: Optional[dt.datetime]
) -> Optional[dt.datetime]:
"""
Set forecast start time for this row
Update passed modified rows list if we changed the row.
Return new start time
"""
changed = False
if self.forecast_start_time != start:
self.forecast_start_time = start
changed = True
if start is None:
if self.forecast_end_time is not None:
self.forecast_end_time = None
changed = True
new_start_time = None
else:
end_time = start + dt.timedelta(milliseconds=self.duration)
new_start_time = end_time
if self.forecast_end_time != end_time:
self.forecast_end_time = end_time
changed = True
if changed and self.row_number not in modified_rows:
modified_rows.append(self.row_number)
return new_start_time
def stop(self, fade_seconds: int = 0) -> None:
"""
Stop this track playing
"""
self.resume_marker = self.music.get_position()
self.fade(fade_seconds)
# Reset fade graph
if self.fade_graph:
self.fade_graph.clear()
def time_playing(self) -> int:
"""
Return time track has been playing in milliseconds, zero if not playing
"""
if self.start_time is None:
return 0
return self.music.get_playtime()
def time_remaining_intro(self) -> int:
"""
Return milliseconds of intro remaining. Return 0 if no intro time in track
record or if intro has finished.
"""
if not self.intro:
return 0
return max(0, self.intro - self.time_playing())
def time_to_fade(self) -> int:
"""
Return milliseconds until fade time. Return zero if we're not playing.
"""
if self.start_time is None:
return 0
return self.fade_at - self.time_playing()
def time_to_silence(self) -> int:
"""
Return milliseconds until silent. Return zero if we're not playing.
"""
if self.start_time is None:
return 0
return self.silence_at - self.time_playing()
def update_fade_graph(self) -> None:
"""
Update fade graph
"""
if (
not self.is_playing()
or not self.fade_graph_start_updates
or not self.fade_graph
):
return
now = dt.datetime.now()
if self.fade_graph_start_updates > now:
return
self.fade_graph.tick(self.time_playing())
def update_playlist_and_row(self, session: Session) -> None:
"""
Update local playlist_id and row_number from playlistrow_id
"""
plr = session.get(PlaylistRows, self.playlistrow_id)
if not plr:
raise ApplicationError(f"(Can't retrieve PlaylistRows entry, {self=}")
self.playlist_id = plr.playlist_id
self.row_number = plr.row_number
class TrackSequence:
next: Optional[RowAndTrack] = None
current: Optional[RowAndTrack] = None
previous: Optional[RowAndTrack] = None
def set_next(self, rat: Optional[RowAndTrack]) -> None:
"""
Set the 'next' track to be passed rat. Clear
any previous next track. If passed rat is None
just clear existing next track.
"""
# Clear any existing fade graph
if self.next and self.next.fade_graph:
self.next.fade_graph.clear()
if rat is None:
self.next = None
else:
self.next = rat
self.next.create_fade_graph()
track_sequence = TrackSequence()

View File

@ -70,12 +70,12 @@ from classes import (
TrackInfo,
)
from config import Config
from dialogs import TrackSelectDialog
from dialogs import TrackInsertDialog
from file_importer import FileImporter
from helpers import ask_yes_no, file_is_unreadable, get_name
from log import log
from log import log, log_call
from models import db, Playdates, PlaylistRows, Playlists, Queries, Settings, Tracks
from music_manager import RowAndTrack, track_sequence
from playlistrow import PlaylistRow, TrackSequence
from playlistmodel import PlaylistModel, PlaylistProxyModel
from playlists import PlaylistTab
from querylistmodel import QuerylistModel
@ -94,12 +94,12 @@ class Current:
base_model: PlaylistModel
proxy_model: PlaylistProxyModel
playlist_id: int = 0
selected_rows: list[int] = []
selected_row_numbers: list[int] = []
def __repr__(self):
return (
f"<Current(base_model={self.base_model}, proxy_model={self.proxy_model}, "
f"playlist_id={self.playlist_id}, selected_rows={self.selected_rows}>"
f"playlist_id={self.playlist_id}, selected_rows={self.selected_row_numbers}>"
)
@ -478,6 +478,7 @@ class ManageQueries(ItemlistManager):
self.populate_table(query_list)
@log_call
def delete_item(self, query_id: int) -> None:
"""delete query"""
@ -490,7 +491,6 @@ class ManageQueries(ItemlistManager):
"Delete query",
f"Delete query '{query.name}': " "Are you sure?",
):
log.debug(f"manage_queries: delete {query=}")
self.session.delete(query)
self.session.commit()
@ -583,6 +583,7 @@ class ManageTemplates(ItemlistManager):
self.populate_table(template_list)
@log_call
def delete_item(self, template_id: int) -> None:
"""delete template"""
@ -606,7 +607,6 @@ class ManageTemplates(ItemlistManager):
else:
self.musicmuster.playlist_section.tabPlaylist.removeTab(open_idx)
log.debug(f"manage_templates: delete {template=}")
self.session.delete(template)
self.session.commit()
@ -1180,7 +1180,7 @@ class Window(QMainWindow):
self.footer_section.widgetFadeVolume.setDefaultPadding(0)
self.footer_section.widgetFadeVolume.setBackground(Config.FADE_CURVE_BACKGROUND)
self.move_source_rows: Optional[list[int]] = None
self.move_source_rows: list[PlaylistRow] = []
self.move_source_model: Optional[PlaylistModel] = None
self.disable_selection_timing = False
@ -1194,6 +1194,7 @@ class Window(QMainWindow):
self.catch_return_key = False
self.importer: Optional[FileImporter] = None
self.current = Current()
self.track_sequence = TrackSequence()
webbrowser.register(
"browser",
@ -1217,7 +1218,7 @@ class Window(QMainWindow):
return
# Don't allow window to close when a track is playing
if track_sequence.current and track_sequence.current.is_playing():
if self.track_sequence.current and self.track_sequence.current.is_playing():
event.ignore()
helpers.show_warning(
self, "Track playing", "Can't close application while track is playing"
@ -1234,7 +1235,6 @@ class Window(QMainWindow):
for playlist_id, idx in open_playlist_ids.items():
playlist = session.get(Playlists, playlist_id)
if playlist:
log.debug(f"Set {playlist=} tab to {idx=}")
playlist.tab = idx
# Save window attributes
@ -1255,9 +1255,6 @@ class Window(QMainWindow):
# # # # # # # # # # Internal utility functions # # # # # # # # # #
def active_base_model(self) -> PlaylistModel:
return self.current.base_model
def active_tab(self) -> PlaylistTab:
return self.playlist_section.tabPlaylist.currentWidget()
@ -1455,6 +1452,7 @@ class Window(QMainWindow):
# # # # # # # # # # Playlist management functions # # # # # # # # # #
@log_call
def _create_playlist(
self, session: Session, name: str, template_id: int
) -> Playlists:
@ -1463,10 +1461,9 @@ class Window(QMainWindow):
if template_id > 0, and return the Playlists object.
"""
log.debug(f" _create_playlist({name=}, {template_id=})")
return Playlists(session, name, template_id)
@log_call
def _open_playlist(self, playlist: Playlists, is_template: bool = False) -> int:
"""
With passed playlist:
@ -1477,8 +1474,6 @@ class Window(QMainWindow):
return: tab index
"""
log.debug(f" _open_playlist({playlist=}, {is_template=})")
# Create base model and proxy model
base_model = PlaylistModel(playlist.id, is_template)
proxy_model = PlaylistProxyModel()
@ -1497,6 +1492,7 @@ class Window(QMainWindow):
return idx
@log_call
def create_playlist_from_template(self, session: Session, template_id: int) -> None:
"""
Prompt for new playlist name and create from passed template_id
@ -1518,7 +1514,8 @@ class Window(QMainWindow):
self._open_playlist(playlist)
session.commit()
def delete_playlist(self) -> None:
@log_call
def delete_playlist(self, checked: bool = False) -> None:
"""
Delete current playlist
"""
@ -1537,7 +1534,7 @@ class Window(QMainWindow):
else:
log.error("Failed to retrieve playlist")
def open_existing_playlist(self) -> None:
def open_existing_playlist(self, checked: bool = False) -> None:
"""Open existing playlist"""
with db.Session() as session:
@ -1549,7 +1546,7 @@ class Window(QMainWindow):
self._open_playlist(playlist)
session.commit()
def save_as_template(self) -> None:
def save_as_template(self, checked: bool = False) -> None:
"""Save current playlist as template"""
with db.Session() as session:
@ -1625,7 +1622,7 @@ class Window(QMainWindow):
# # # # # # # # # # Manage templates and queries # # # # # # # # # #
def manage_queries_wrapper(self):
def manage_queries_wrapper(self, checked: bool = False) -> None:
"""
Simply instantiate the manage_queries class
"""
@ -1633,7 +1630,7 @@ class Window(QMainWindow):
with db.Session() as session:
_ = ManageQueries(session, self)
def manage_templates_wrapper(self):
def manage_templates_wrapper(self, checked: bool = False) -> None:
"""
Simply instantiate the manage_queries class
"""
@ -1643,12 +1640,12 @@ class Window(QMainWindow):
# # # # # # # # # # Miscellaneous functions # # # # # # # # # #
def select_duplicate_rows(self) -> None:
def select_duplicate_rows(self, checked: bool = False) -> None:
"""Call playlist to select duplicate rows"""
self.active_tab().select_duplicate_rows()
def about(self) -> None:
def about(self, checked: bool = False) -> None:
"""Get git tag and database name"""
try:
@ -1674,10 +1671,10 @@ class Window(QMainWindow):
Clear next track
"""
track_sequence.set_next(None)
self.track_sequence.set_next(None)
self.update_headers()
def clear_selection(self) -> None:
def clear_selection(self, checked: bool = False) -> None:
"""Clear row selection"""
# Unselect any selected rows
@ -1687,7 +1684,7 @@ class Window(QMainWindow):
# Clear the search bar
self.search_playlist_clear()
def close_playlist_tab(self) -> bool:
def close_playlist_tab(self, checked: bool = False) -> bool:
"""
Close active playlist tab, called by menu item
"""
@ -1707,8 +1704,8 @@ class Window(QMainWindow):
).playlist_id
# Don't close current track playlist
if track_sequence.current is not None:
current_track_playlist_id = track_sequence.current.playlist_id
if self.track_sequence.current is not None:
current_track_playlist_id = self.track_sequence.current.playlist_id
if current_track_playlist_id:
if closing_tab_playlist_id == current_track_playlist_id:
helpers.show_OK(
@ -1717,8 +1714,8 @@ class Window(QMainWindow):
return False
# Don't close next track playlist
if track_sequence.next is not None:
next_track_playlist_id = track_sequence.next.playlist_id
if self.track_sequence.next is not None:
next_track_playlist_id = self.track_sequence.next.playlist_id
if next_track_playlist_id:
if closing_tab_playlist_id == next_track_playlist_id:
helpers.show_OK(
@ -1773,6 +1770,7 @@ class Window(QMainWindow):
self.signals.search_songfacts_signal.connect(self.open_songfacts_browser)
self.signals.search_wikipedia_signal.connect(self.open_wikipedia_browser)
@log_call
def current_row_or_end(self) -> int:
"""
If a row or rows are selected, return the row number of the first
@ -1780,18 +1778,18 @@ class Window(QMainWindow):
of the playlist.
"""
if self.current.selected_rows:
return self.current.selected_rows[0]
if self.current.selected_row_numbers:
return self.current.selected_row_numbers[0]
return self.current.base_model.rowCount()
def debug(self):
def debug(self, checked: bool = False) -> None:
"""Invoke debugger"""
import ipdb # type: ignore
ipdb.set_trace()
def download_played_tracks(self) -> None:
def download_played_tracks(self, checked: bool = False) -> None:
"""Download a CSV of played tracks"""
dlg = DownloadCSV(self)
@ -1819,9 +1817,10 @@ class Window(QMainWindow):
def drop3db(self) -> None:
"""Drop music level by 3db if button checked"""
if track_sequence.current:
track_sequence.current.drop3db(self.footer_section.btnDrop3db.isChecked())
if self.track_sequence.current:
self.track_sequence.current.drop3db(self.footer_section.btnDrop3db.isChecked())
@log_call
def enable_escape(self, enabled: bool) -> None:
"""
Manage signal to enable/disable handling ESC character.
@ -1830,11 +1829,10 @@ class Window(QMainWindow):
so we need to disable it here while editing.
"""
log.debug(f"enable_escape({enabled=})")
if "clear_selection" in self.menu_actions:
self.menu_actions["clear_selection"].setEnabled(enabled)
@log_call
def end_of_track_actions(self) -> None:
"""
@ -1846,13 +1844,8 @@ class Window(QMainWindow):
- Enable controls
"""
if track_sequence.current:
# Dereference the fade curve so it can be garbage collected
track_sequence.current.fade_graph = None
# Reset track_sequence objects
track_sequence.previous = track_sequence.current
track_sequence.current = None
if self.track_sequence.current:
self.track_sequence.move_current_to_previous()
# Tell playlist previous track has finished
self.current.base_model.previous_track_ended()
@ -1874,7 +1867,7 @@ class Window(QMainWindow):
# if not self.stop_autoplay:
# self.play_next()
def export_playlist_tab(self) -> None:
def export_playlist_tab(self, checked: bool = False) -> None:
"""Export the current playlist to an m3u file"""
playlist_id = self.current.playlist_id
@ -1915,11 +1908,11 @@ class Window(QMainWindow):
"\n"
)
def fade(self) -> None:
def fade(self, checked: bool = False) -> None:
"""Fade currently playing track"""
if track_sequence.current:
track_sequence.current.fade()
if self.track_sequence.current:
self.track_sequence.current.fade()
def get_tab_index_for_playlist(self, playlist_id: int) -> Optional[int]:
"""
@ -1951,7 +1944,7 @@ class Window(QMainWindow):
# Reset row heights
self.active_tab().resize_rows()
def import_files_wrapper(self) -> None:
def import_files_wrapper(self, checked: bool = False) -> None:
"""
Pass import files call to file_importer module
"""
@ -1961,7 +1954,7 @@ class Window(QMainWindow):
self.importer = FileImporter(self.current.base_model, self.current_row_or_end())
self.importer.start()
def insert_header(self) -> None:
def insert_header(self, checked: bool = False) -> None:
"""Show dialog box to enter header text and add to playlist"""
# Get header text
@ -1976,19 +1969,16 @@ class Window(QMainWindow):
note=dlg.textValue(),
)
def insert_track(self) -> None:
def insert_track(self, checked: bool = False) -> None:
"""Show dialog box to select and add track from database"""
with db.Session() as session:
dlg = TrackSelectDialog(
parent=self,
session=session,
new_row_number=self.current_row_or_end(),
base_model=self.current.base_model,
)
dlg.exec()
session.commit()
dlg = TrackInsertDialog(
parent=self,
playlist_id=self.active_tab().playlist_id
)
dlg.exec()
@log_call
def load_last_playlists(self) -> None:
"""Load the playlists that were open when the last session closed"""
@ -1996,7 +1986,6 @@ class Window(QMainWindow):
with db.Session() as session:
for playlist in Playlists.get_open(session):
if playlist:
log.debug(f"load_last_playlists() loaded {playlist=}")
# Create tab
playlist_ids.append(self._open_playlist(playlist))
@ -2012,7 +2001,7 @@ class Window(QMainWindow):
Playlists.clear_tabs(session, playlist_ids)
session.commit()
def lookup_row_in_songfacts(self) -> None:
def lookup_row_in_songfacts(self, checked: bool = False) -> None:
"""
Display songfacts page for title in highlighted row
"""
@ -2023,7 +2012,7 @@ class Window(QMainWindow):
self.signals.search_songfacts_signal.emit(track_info.title)
def lookup_row_in_wikipedia(self) -> None:
def lookup_row_in_wikipedia(self, checked: bool = False) -> None:
"""
Display Wikipedia page for title in highlighted row or next track
"""
@ -2034,20 +2023,21 @@ class Window(QMainWindow):
self.signals.search_wikipedia_signal.emit(track_info.title)
def mark_rows_for_moving(self) -> None:
def mark_rows_for_moving(self, checked: bool = False) -> None:
"""
Cut rows ready for pasting.
"""
# Save the selected PlaylistRows items ready for a later
# paste
self.move_source_rows = self.current.selected_rows
self.move_source_rows = self.current.base_model.selected_rows
self.move_source_model = self.current.base_model
log.debug(
f"mark_rows_for_moving(): {self.move_source_rows=} {self.move_source_model=}"
)
@log_call
def move_playlist_rows(self, row_numbers: list[int]) -> None:
"""
Move passed playlist rows to another playlist
@ -2083,27 +2073,20 @@ class Window(QMainWindow):
)
# Reset track_sequences
with db.Session() as session:
for ts in [
track_sequence.next,
track_sequence.current,
track_sequence.previous,
]:
if ts:
ts.update_playlist_and_row(session)
self.track_sequence.update()
def move_selected(self) -> None:
def move_selected(self, checked: bool = False) -> None:
"""
Move selected rows to another playlist
"""
selected_rows = self.current.selected_rows
selected_rows = self.current.selected_row_numbers
if not selected_rows:
return
self.move_playlist_rows(selected_rows)
def move_unplayed(self) -> None:
def move_unplayed(self, checked: bool = False) -> None:
"""
Move unplayed rows to another playlist
"""
@ -2135,7 +2118,8 @@ class Window(QMainWindow):
webbrowser.get("browser").open_new_tab(url)
def paste_rows(self, dummy_for_profiling: int | None = None) -> None:
@log_call
def paste_rows(self, checked: bool = False) -> None:
"""
Paste earlier cut rows.
"""
@ -2150,9 +2134,9 @@ class Window(QMainWindow):
# that moved row the next track
set_next_row: Optional[int] = None
if (
track_sequence.current
and track_sequence.current.playlist_id == to_playlist_model.playlist_id
and destination_row == track_sequence.current.row_number + 1
self.track_sequence.current
and self.track_sequence.current.playlist_id == to_playlist_model.playlist_id
and destination_row == self.track_sequence.current.row_number + 1
):
set_next_row = destination_row
@ -2168,7 +2152,8 @@ class Window(QMainWindow):
if set_next_row:
to_playlist_model.set_next_row(set_next_row)
def play_next(self, position: Optional[float] = None) -> None:
@log_call
def play_next(self, position: Optional[float] = None, checked: bool = False) -> None:
"""
Play next track, optionally from passed position.
@ -2188,7 +2173,7 @@ class Window(QMainWindow):
"""
# If there is no next track set, return.
if track_sequence.next is None:
if self.track_sequence.next is None:
log.error("musicmuster.play_next(): no next track selected")
return
@ -2205,35 +2190,34 @@ class Window(QMainWindow):
log.debug("issue223: play_next: 10ms timer disabled")
# If there's currently a track playing, fade it.
if track_sequence.current:
track_sequence.current.fade()
if self.track_sequence.current:
self.track_sequence.current.fade()
# Move next track to current track.
# end_of_track_actions() will have saved current track to
# previous_track
track_sequence.current = track_sequence.next
# Clear next track
self.clear_next()
self.track_sequence.move_next_to_current()
if self.track_sequence.current is None:
raise ApplicationError("No current track")
# Restore volume if -3dB active
if self.footer_section.btnDrop3db.isChecked():
self.footer_section.btnDrop3db.setChecked(False)
# Play (new) current track
log.debug(f"Play: {track_sequence.current.title}")
track_sequence.current.play(position)
log.debug(f"Play: {self.track_sequence.current.title}")
self.track_sequence.current.play(position)
# Update clocks now, don't wait for next tick
self.update_clocks()
# Show closing volume graph
if track_sequence.current.fade_graph:
track_sequence.current.fade_graph.GraphWidget = (
if self.track_sequence.current.fade_graph:
self.track_sequence.current.fade_graph.GraphWidget = (
self.footer_section.widgetFadeVolume
)
track_sequence.current.fade_graph.clear()
track_sequence.current.fade_graph.plot()
self.track_sequence.current.fade_graph.clear()
self.track_sequence.current.fade_graph.plot()
# Disable play next controls
self.catch_return_key = True
@ -2266,10 +2250,10 @@ class Window(QMainWindow):
track_info = self.active_tab().get_selected_row_track_info()
if not track_info:
# Otherwise get track_id to next track to play
if track_sequence.next:
if track_sequence.next.track_id:
if self.track_sequence.next:
if self.track_sequence.next.track_id:
track_info = TrackInfo(
track_sequence.next.track_id, track_sequence.next.row_number
self.track_sequence.next.track_id, self.track_sequence.next.row_number
)
else:
return
@ -2364,7 +2348,7 @@ class Window(QMainWindow):
if ok:
log.debug("quicklog: " + dlg.textValue())
def rename_playlist(self) -> None:
def rename_playlist(self, checked: bool = False) -> None:
"""
Rename current playlist
"""
@ -2387,12 +2371,12 @@ class Window(QMainWindow):
Return True if it has, False if not
"""
if track_sequence.current and self.catch_return_key:
if self.track_sequence.current and self.catch_return_key:
# Suppress inadvertent double press
if (
track_sequence.current
and track_sequence.current.start_time
and track_sequence.current.start_time
self.track_sequence.current
and self.track_sequence.current.start_time
and self.track_sequence.current.start_time
+ dt.timedelta(milliseconds=Config.RETURN_KEY_DEBOUNCE_MS)
> dt.datetime.now()
):
@ -2401,8 +2385,8 @@ class Window(QMainWindow):
# If return is pressed during first PLAY_NEXT_GUARD_MS then
# default to NOT playing the next track, else default to
# playing it.
default_yes: bool = track_sequence.current.start_time is not None and (
(dt.datetime.now() - track_sequence.current.start_time).total_seconds()
default_yes: bool = self.track_sequence.current.start_time is not None and (
(dt.datetime.now() - self.track_sequence.current.start_time).total_seconds()
* 1000
> Config.PLAY_NEXT_GUARD_MS
)
@ -2420,7 +2404,7 @@ class Window(QMainWindow):
return False
def resume(self) -> None:
def resume(self, checked: bool = False) -> None:
"""
Resume playing last track. We may be playing the next track
or none; take care of both eventualities.
@ -2431,18 +2415,18 @@ class Window(QMainWindow):
- If a track is playing, make that the next track
"""
if not track_sequence.previous:
if not self.track_sequence.previous:
return
# Return if no saved position
resume_marker = track_sequence.previous.resume_marker
resume_marker = self.track_sequence.previous.resume_marker
if not resume_marker:
log.error("No previous track position")
return
# We want to use play_next() to resume, so copy the previous
# track to the next track:
track_sequence.set_next(track_sequence.previous)
self.track_sequence.move_previous_to_next()
# Now resume playing the now-next track
self.play_next(resume_marker)
@ -2451,17 +2435,17 @@ class Window(QMainWindow):
# We need to fake the start time to reflect where we resumed the
# track
if (
track_sequence.current
and track_sequence.current.start_time
and track_sequence.current.duration
and track_sequence.current.resume_marker
self.track_sequence.current
and self.track_sequence.current.start_time
and self.track_sequence.current.duration
and self.track_sequence.current.resume_marker
):
elapsed_ms = (
track_sequence.current.duration * track_sequence.current.resume_marker
self.track_sequence.current.duration * self.track_sequence.current.resume_marker
)
track_sequence.current.start_time -= dt.timedelta(milliseconds=elapsed_ms)
self.track_sequence.current.start_time -= dt.timedelta(milliseconds=elapsed_ms)
def search_playlist(self) -> None:
def search_playlist(self, checked: bool = False) -> None:
"""Show text box to search playlist"""
# Disable play controls so that 'return' in search box doesn't
@ -2486,7 +2470,7 @@ class Window(QMainWindow):
self.current.proxy_model.set_incremental_search(self.txtSearch.text())
def selected_or_next_track_info(self) -> Optional[RowAndTrack]:
def selected_or_next_track_info(self) -> Optional[PlaylistRow]:
"""
Return RowAndTrack info for selected track. If no selected track, return for
next track. If no next track, return None.
@ -2494,12 +2478,12 @@ class Window(QMainWindow):
row_number: Optional[int] = None
if self.current.selected_rows:
row_number = self.current.selected_rows[0]
if self.current.selected_row_numbers:
row_number = self.current.selected_row_numbers[0]
if row_number is None:
if track_sequence.next:
if track_sequence.next.track_id:
row_number = track_sequence.next.row_number
if self.track_sequence.next:
if self.track_sequence.next.track_id:
row_number = self.track_sequence.next.row_number
if row_number is None:
return None
@ -2519,17 +2503,21 @@ class Window(QMainWindow):
height = Settings.get_setting(session, "mainwindow_height").f_int or 100
self.setGeometry(x, y, width, height)
def set_selected_track_next(self) -> None:
@log_call
def set_selected_track_next(self, checked: bool = False) -> None:
"""
Set currently-selected row on visible playlist tab as next track
"""
playlist_tab = self.active_tab()
if playlist_tab:
playlist_tab.set_row_as_next_track()
else:
log.error("No active tab")
self.signals.signal_set_next_row.emit(self.current.playlist_id)
self.clear_selection()
# playlist_tab = self.active_tab()
# if playlist_tab:
# playlist_tab.set_row_as_next_track()
# else:
# log.error("No active tab")
@log_call
def set_tab_colour(self, widget: PlaylistTab, colour: QColor) -> None:
"""
Find the tab containing the widget and set the text colour
@ -2541,8 +2529,8 @@ class Window(QMainWindow):
def show_current(self) -> None:
"""Scroll to show current track"""
if track_sequence.current:
self.show_track(track_sequence.current)
if self.track_sequence.current:
self.show_track(self.track_sequence.current)
def show_warning(self, title: str, body: str) -> None:
"""
@ -2555,8 +2543,8 @@ class Window(QMainWindow):
def show_next(self) -> None:
"""Scroll to show next track"""
if track_sequence.next:
self.show_track(track_sequence.next)
if self.track_sequence.next:
self.show_track(self.track_sequence.next)
def show_status_message(self, message: str, timing: int) -> None:
"""
@ -2570,7 +2558,7 @@ class Window(QMainWindow):
else:
self.statusbar.clearMessage()
def show_track(self, playlist_track: RowAndTrack) -> None:
def show_track(self, playlist_track: PlaylistRow) -> None:
"""Scroll to show track in plt"""
# Switch to the correct tab
@ -2591,12 +2579,13 @@ class Window(QMainWindow):
self.active_tab().scroll_to_top(playlist_track.row_number)
def stop(self) -> None:
@log_call
def stop(self, checked: bool = False) -> None:
"""Stop playing immediately"""
self.stop_autoplay = True
if track_sequence.current:
track_sequence.current.stop()
if self.track_sequence.current:
self.track_sequence.current.stop()
def tab_change(self) -> None:
"""Called when active tab changed"""
@ -2608,22 +2597,22 @@ class Window(QMainWindow):
Called every 10ms
"""
if track_sequence.current:
track_sequence.current.update_fade_graph()
if self.track_sequence.current:
self.track_sequence.current.update_fade_graph()
def tick_100ms(self) -> None:
"""
Called every 100ms
"""
if track_sequence.current:
if self.track_sequence.current:
try:
track_sequence.current.check_for_end_of_track()
self.track_sequence.current.check_for_end_of_track()
# Update intro counter if applicable and, if updated, return
# because playing an intro takes precedence over timing a
# preview.
intro_ms_remaining = track_sequence.current.time_remaining_intro()
intro_ms_remaining = self.track_sequence.current.time_remaining_intro()
if intro_ms_remaining > 0:
self.footer_section.label_intro_timer.setText(
f"{intro_ms_remaining / 1000:.1f}"
@ -2683,17 +2672,17 @@ class Window(QMainWindow):
"""
# If track is playing, update track clocks time and colours
if track_sequence.current and track_sequence.current.is_playing():
if self.track_sequence.current and self.track_sequence.current.is_playing():
# Elapsed time
self.header_section.label_elapsed_timer.setText(
helpers.ms_to_mmss(track_sequence.current.time_playing())
helpers.ms_to_mmss(self.track_sequence.current.time_playing())
+ " / "
+ helpers.ms_to_mmss(track_sequence.current.duration)
+ helpers.ms_to_mmss(self.track_sequence.current.duration)
)
# Time to fade
time_to_fade = track_sequence.current.time_to_fade()
time_to_silence = track_sequence.current.time_to_silence()
time_to_fade = self.track_sequence.current.time_to_fade()
time_to_silence = self.track_sequence.current.time_to_silence()
self.footer_section.label_fade_timer.setText(
helpers.ms_to_mmss(time_to_fade)
)
@ -2725,6 +2714,7 @@ class Window(QMainWindow):
self.catch_return_key = False
self.show_status_message("Play controls: Enabled", 0)
# Re-enable 10ms timer (see above)
log.debug(f"issue287: {self.timer10.isActive()=}")
if not self.timer10.isActive():
self.timer10.start(10)
log.debug("issue223: update_clocks: 10ms timer enabled")
@ -2742,25 +2732,25 @@ class Window(QMainWindow):
Update last / current / next track headers
"""
if track_sequence.previous:
if self.track_sequence.previous:
self.header_section.hdrPreviousTrack.setText(
f"{track_sequence.previous.title} - {track_sequence.previous.artist}"
f"{self.track_sequence.previous.title} - {self.track_sequence.previous.artist}"
)
else:
self.header_section.hdrPreviousTrack.setText("")
if track_sequence.current:
if self.track_sequence.current:
self.header_section.hdrCurrentTrack.setText(
f"{track_sequence.current.title.replace('&', '&&')} - "
f"{track_sequence.current.artist.replace('&', '&&')}"
f"{self.track_sequence.current.title.replace('&', '&&')} - "
f"{self.track_sequence.current.artist.replace('&', '&&')}"
)
else:
self.header_section.hdrCurrentTrack.setText("")
if track_sequence.next:
if self.track_sequence.next:
self.header_section.hdrNextTrack.setText(
f"{track_sequence.next.title.replace('&', '&&')} - "
f"{track_sequence.next.artist.replace('&', '&&')}"
f"{self.track_sequence.next.title.replace('&', '&&')} - "
f"{self.track_sequence.next.artist.replace('&', '&&')}"
)
else:
self.header_section.hdrNextTrack.setText("")
@ -2775,25 +2765,25 @@ class Window(QMainWindow):
# Do we need to set a 'next' icon?
set_next = True
if (
track_sequence.current
and track_sequence.next
and track_sequence.current.playlist_id == track_sequence.next.playlist_id
self.track_sequence.current
and self.track_sequence.next
and self.track_sequence.current.playlist_id == self.track_sequence.next.playlist_id
):
set_next = False
for idx in range(self.tabBar.count()):
widget = self.playlist_section.tabPlaylist.widget(idx)
if (
track_sequence.next
self.track_sequence.next
and set_next
and widget.playlist_id == track_sequence.next.playlist_id
and widget.playlist_id == self.track_sequence.next.playlist_id
):
self.playlist_section.tabPlaylist.setTabIcon(
idx, QIcon(Config.PLAYLIST_ICON_NEXT)
)
elif (
track_sequence.current
and widget.playlist_id == track_sequence.current.playlist_id
self.track_sequence.current
and widget.playlist_id == self.track_sequence.current.playlist_id
):
self.playlist_section.tabPlaylist.setTabIcon(
idx, QIcon(Config.PLAYLIST_ICON_CURRENT)

File diff suppressed because it is too large Load Diff

533
app/playlistrow.py Normal file
View File

@ -0,0 +1,533 @@
# Standard library imports
import datetime as dt
from typing import Any
# PyQt imports
from PyQt6.QtCore import (
pyqtSignal,
QObject,
QThread,
)
# Third party imports
from pyqtgraph import PlotWidget # type: ignore
from pyqtgraph.graphicsItems.PlotDataItem import PlotDataItem # type: ignore
from pyqtgraph.graphicsItems.LinearRegionItem import LinearRegionItem # type: ignore
import numpy as np
import pyqtgraph as pg # type: ignore
# App imports
from classes import ApplicationError, MusicMusterSignals, PlaylistRowDTO, singleton
from config import Config
import helpers
from log import log
from music_manager import Music
import repository
class PlaylistRow:
"""
Object to manage playlist row and track.
"""
def __init__(self, dto: PlaylistRowDTO) -> None:
"""
The dto object will include a Tracks object if this row has a track.
"""
self.dto = dto
self.music = Music(name=Config.VLC_MAIN_PLAYER_NAME)
self.signals = MusicMusterSignals()
self.end_of_track_signalled: bool = False
self.end_time: dt.datetime | None = None
self.fade_graph: Any | None = None
self.fade_graph_start_updates: dt.datetime | None = None
self.forecast_end_time: dt.datetime | None = None
self.forecast_start_time: dt.datetime | None = None
self.note_bg: str | None = None
self.note_fg: str | None = None
self.resume_marker: float = 0.0
self.row_bg: str | None = None
self.row_fg: str | None = None
self.start_time: dt.datetime | None = None
def __repr__(self) -> str:
return (
f"<PlaylistRow(playlist_id={self.dto.playlist_id}, "
f"row_number={self.dto.row_number}, "
f"playlistrow_id={self.dto.playlistrow_id}, "
f"note={self.dto.note}, track_id={self.dto.track_id}>"
)
# Expose TrackDTO fields as properties
@property
def artist(self):
return self.dto.artist
@property
def bitrate(self):
return self.dto.bitrate
@property
def duration(self):
return self.dto.duration
@property
def fade_at(self):
return self.dto.fade_at
@property
def intro(self):
return self.dto.intro
@property
def lastplayed(self):
return self.dto.lastplayed
@property
def path(self):
return self.dto.path
@property
def silence_at(self):
return self.dto.silence_at
@property
def start_gap(self):
return self.dto.start_gap
@property
def title(self):
return self.dto.title
@property
def track_id(self):
return self.dto.track_id
@track_id.setter
def track_id(self, value: int) -> None:
"""
Adding a track_id should only happen to a header row.
"""
if self.track_id:
raise ApplicationError("Attempting to add track to row with existing track ({self=}")
# TODO: set up write access to track_id. Should only update if
# track_id == 0. Need to update all other track fields at the
# same time.
print("set track_id attribute for {self=}, {value=}")
pass
# Expose PlaylistRowDTO fields as properties
@property
def note(self):
return self.dto.note
@note.setter
def note(self, value: str) -> None:
# TODO set up write access to db
print("set note attribute for {self=}, {value=}")
# self.dto.note = value
@property
def played(self):
return self.dto.played
@played.setter
def played(self, value: bool = True) -> None:
# TODO set up write access to db
print("set played attribute for {self=}")
# self.dto.played = value
@property
def playlist_id(self):
return self.dto.playlist_id
@property
def playlistrow_id(self):
return self.dto.playlistrow_id
@property
def row_number(self):
return self.dto.row_number
@row_number.setter
def row_number(self, value: int) -> None:
# TODO do we need to set up write access to db?
self.dto.row_number = value
def check_for_end_of_track(self) -> None:
"""
Check whether track has ended. If so, emit track_ended_signal
"""
if self.start_time is None:
return
if self.end_of_track_signalled:
return
if self.music.is_playing():
return
self.start_time = None
if self.fade_graph:
self.fade_graph.clear()
# Ensure that player is released
self.music.fade(0)
self.signals.track_ended_signal.emit()
self.end_of_track_signalled = True
def drop3db(self, enable: bool) -> None:
"""
If enable is true, drop output by 3db else restore to full volume
"""
if enable:
self.music.set_volume(volume=Config.VLC_VOLUME_DROP3db, set_default=False)
else:
self.music.set_volume(volume=Config.VLC_VOLUME_DEFAULT, set_default=False)
def fade(self, fade_seconds: int = Config.FADEOUT_SECONDS) -> None:
"""Fade music"""
self.resume_marker = self.music.get_position()
self.music.fade(fade_seconds)
self.signals.track_ended_signal.emit()
def is_playing(self) -> bool:
"""
Return True if we're currently playing else False
"""
if self.start_time is None:
return False
return self.music.is_playing()
def play(self, position: float | None = None) -> None:
"""Play track"""
now = dt.datetime.now()
self.start_time = now
# Initialise player
self.music.play(self.path, start_time=now, position=position)
self.end_time = now + dt.timedelta(milliseconds=self.duration)
# Calculate time fade_graph should start updating
if self.fade_at:
update_graph_at_ms = max(
0, self.fade_at - Config.FADE_CURVE_MS_BEFORE_FADE - 1
)
self.fade_graph_start_updates = now + dt.timedelta(
milliseconds=update_graph_at_ms
)
def set_forecast_start_time(
self, modified_rows: list[int], start: dt.datetime | None
) -> dt.datetime | None:
"""
Set forecast start time for this row
Update passed modified rows list if we changed the row.
Return new start time
"""
changed = False
if self.forecast_start_time != start:
self.forecast_start_time = start
changed = True
if start is None:
if self.forecast_end_time is not None:
self.forecast_end_time = None
changed = True
new_start_time = None
else:
end_time = start + dt.timedelta(milliseconds=self.duration)
new_start_time = end_time
if self.forecast_end_time != end_time:
self.forecast_end_time = end_time
changed = True
if changed and self.row_number not in modified_rows:
modified_rows.append(self.row_number)
return new_start_time
def stop(self, fade_seconds: int = 0) -> None:
"""
Stop this track playing
"""
self.resume_marker = self.music.get_position()
self.fade(fade_seconds)
# Reset fade graph
if self.fade_graph:
self.fade_graph.clear()
def time_playing(self) -> int:
"""
Return time track has been playing in milliseconds, zero if not playing
"""
if self.start_time is None:
return 0
return self.music.get_playtime()
def time_remaining_intro(self) -> int:
"""
Return milliseconds of intro remaining. Return 0 if no intro time in track
record or if intro has finished.
"""
if not self.intro:
return 0
return max(0, self.intro - self.time_playing())
def time_to_fade(self) -> int:
"""
Return milliseconds until fade time. Return zero if we're not playing.
"""
if self.start_time is None:
return 0
return self.fade_at - self.time_playing()
def time_to_silence(self) -> int:
"""
Return milliseconds until silent. Return zero if we're not playing.
"""
if self.start_time is None:
return 0
return self.silence_at - self.time_playing()
def update_fade_graph(self) -> None:
"""
Update fade graph
"""
if (
not self.is_playing()
or not self.fade_graph_start_updates
or not self.fade_graph
):
return
now = dt.datetime.now()
if self.fade_graph_start_updates > now:
return
self.fade_graph.tick(self.time_playing())
class _AddFadeCurve(QObject):
"""
Initialising a fade curve introduces a noticeable delay so carry out in
a thread.
"""
finished = pyqtSignal()
def __init__(
self,
plr: PlaylistRow,
track_path: str,
track_fade_at: int,
track_silence_at: int,
) -> None:
super().__init__()
self.plr = plr
self.track_path = track_path
self.track_fade_at = track_fade_at
self.track_silence_at = track_silence_at
def run(self) -> None:
"""
Create fade curve and add to PlaylistTrack object
"""
fc = FadeCurve(self.track_path, self.track_fade_at, self.track_silence_at)
if not fc:
log.error(f"Failed to create FadeCurve for {self.track_path=}")
else:
self.plr.fade_graph = fc
self.finished.emit()
class FadeCurve:
GraphWidget: PlotWidget | None = None
def __init__(
self, track_path: str, track_fade_at: int, track_silence_at: int
) -> None:
"""
Set up fade graph array
"""
audio = helpers.get_audio_segment(track_path)
if not audio:
log.error(f"FadeCurve: could not get audio for {track_path=}")
return None
# Start point of curve is Config.FADE_CURVE_MS_BEFORE_FADE
# milliseconds before fade starts to silence
self.start_ms: int = max(
0, track_fade_at - Config.FADE_CURVE_MS_BEFORE_FADE - 1
)
self.end_ms: int = track_silence_at
audio_segment = audio[self.start_ms : self.end_ms]
self.graph_array = np.array(audio_segment.get_array_of_samples())
# Calculate the factor to map milliseconds of track to array
self.ms_to_array_factor = len(self.graph_array) / (self.end_ms - self.start_ms)
self.curve: PlotDataItem | None = None
self.region: LinearRegionItem | None = None
def clear(self) -> None:
"""Clear the current graph"""
if self.GraphWidget:
self.GraphWidget.clear()
def plot(self) -> None:
if self.GraphWidget:
self.curve = self.GraphWidget.plot(self.graph_array)
if self.curve:
self.curve.setPen(Config.FADE_CURVE_FOREGROUND)
else:
log.debug("_FadeCurve.plot: no curve")
else:
log.debug("_FadeCurve.plot: no GraphWidget")
def tick(self, play_time: int) -> None:
"""Update volume fade curve"""
if not self.GraphWidget:
return
ms_of_graph = play_time - self.start_ms
if ms_of_graph < 0:
return
if self.region is None:
# Create the region now that we're into fade
self.region = pg.LinearRegionItem([0, 0], bounds=[0, len(self.graph_array)])
self.GraphWidget.addItem(self.region)
# Update region position
if self.region:
self.region.setRegion([0, ms_of_graph * self.ms_to_array_factor])
@singleton
class TrackSequence:
"""
Maintain a list of which track (if any) is next, current and
previous. A track can only be previous after being current, and can
only be current after being next. If one of the tracks listed here
moves, the row_number and/or playlist_id will change.
"""
def __init__(self) -> None:
"""
Set up storage for the three monitored tracks
"""
self.next: PlaylistRow | None = None
self.current: PlaylistRow | None = None
self.previous: PlaylistRow | None = None
def set_next(self, plr: PlaylistRow | None) -> None:
"""
Set the 'next' track to be passed PlaylistRow. Clear any previous
next track. If passed PlaylistRow is None just clear existing
next track.
"""
# Clear any existing fade graph
if self.next and self.next.fade_graph:
self.next.fade_graph.clear()
if plr is None:
self.next = None
else:
self.next = plr
self.create_fade_graph()
def move_next_to_current(self) -> None:
"""
Make the next track the current track
"""
self.current = self.next
self.next = None
def move_current_to_previous(self) -> None:
"""
Make the current track the previous track
"""
if self.current is None:
raise ApplicationError("Tried to move non-existent track from current to previous")
# Dereference the fade curve so it can be garbage collected
self.current.fade_graph = None
self.previous = self.current
self.current = None
def move_previous_to_next(self) -> None:
"""
Make the previous track the next track
"""
self.next = self.previous
self.previous = None
def create_fade_graph(self) -> None:
"""
Initialise and add FadeCurve in a thread as it's slow
"""
self.fadecurve_thread = QThread()
if self.next is None:
raise ApplicationError("hell in a handcart")
self.worker = _AddFadeCurve(
self.next,
track_path=self.next.path,
track_fade_at=self.next.fade_at,
track_silence_at=self.next.silence_at,
)
self.worker.moveToThread(self.fadecurve_thread)
self.fadecurve_thread.started.connect(self.worker.run)
self.worker.finished.connect(self.fadecurve_thread.quit)
self.worker.finished.connect(self.worker.deleteLater)
self.fadecurve_thread.finished.connect(self.fadecurve_thread.deleteLater)
self.fadecurve_thread.start()
def update(self) -> None:
"""
If a PlaylistRow is edited (moved, title changed, etc), the
playlistrow_id won't change. We can retrieve the PlaylistRow
using the playlistrow_id and update the stored PlaylistRow.
"""
for ts in [self.next, self.current, self.previous]:
if not ts:
continue
playlist_row_dto = repository.get_playlist_row(ts.playlistrow_id)
if not playlist_row_dto:
raise ApplicationError(f"(Can't retrieve PlaylistRows entry, {self=}")
ts = PlaylistRow(playlist_row_dto)

View File

@ -37,16 +37,16 @@ from PyQt6.QtWidgets import (
from audacity_controller import AudacityController
from classes import ApplicationError, Col, MusicMusterSignals, PlaylistStyle, TrackInfo
from config import Config
from dialogs import TrackSelectDialog
from dialogs import TrackInsertDialog
from helpers import (
ask_yes_no,
ms_to_mmss,
show_OK,
show_warning,
)
from log import log
from log import log, log_call
from models import db, Settings
from music_manager import track_sequence
from playlistrow import TrackSequence
from playlistmodel import PlaylistModel, PlaylistProxyModel
if TYPE_CHECKING:
@ -278,6 +278,7 @@ class PlaylistTab(QTableView):
self.musicmuster = musicmuster
self.playlist_id = model.sourceModel().playlist_id
self.track_sequence = TrackSequence()
# Set up widget
self.setItemDelegate(PlaylistDelegate(self, model.sourceModel()))
@ -358,7 +359,8 @@ class PlaylistTab(QTableView):
# Deselect edited line
self.clear_selection()
def dropEvent(self, event: Optional[QDropEvent], dummy: int | None = None) -> None:
@log_call
def dropEvent(self, event: Optional[QDropEvent]) -> None:
"""
Move dropped rows
"""
@ -394,9 +396,6 @@ class PlaylistTab(QTableView):
destination_index = to_index
to_model_row = self.model().mapToSource(destination_index).row()
log.debug(
f"PlaylistTab.dropEvent(): {from_rows=}, {destination_index=}, {to_model_row=}"
)
# Sanity check
base_model_row_count = self.get_base_model().rowCount()
@ -408,8 +407,8 @@ class PlaylistTab(QTableView):
# that moved row the next track
set_next_row: Optional[int] = None
if (
track_sequence.current
and to_model_row == track_sequence.current.row_number + 1
self.track_sequence.current
and to_model_row == self.track_sequence.current.row_number + 1
):
set_next_row = to_model_row
@ -456,14 +455,19 @@ class PlaylistTab(QTableView):
self, selected: QItemSelection, deselected: QItemSelection
) -> None:
"""
Tell model which rows are selected.
Toggle drag behaviour according to whether rows are selected
"""
selected_rows = self.get_selected_rows()
self.musicmuster.current.selected_rows = selected_rows
selected_row_numbers = self.get_selected_rows()
# Signal selected rows to model
self.signals.signal_playlist_selected_rows.emit(self.playlist_id, selected_row_numbers)
# Put sum of selected tracks' duration in status bar
# If no rows are selected, we have nothing to do
if len(selected_rows) == 0:
if len(selected_row_numbers) == 0:
self.musicmuster.lblSumPlaytime.setText("")
else:
if not self.musicmuster.disable_selection_timing:
@ -514,11 +518,9 @@ class PlaylistTab(QTableView):
return
with db.Session() as session:
dlg = TrackSelectDialog(
dlg = TrackInsertDialog(
parent=self.musicmuster,
session=session,
new_row_number=model_row_number,
base_model=self.get_base_model(),
playlist_id=self.playlist_id,
add_to_header=True,
)
dlg.exec()
@ -535,12 +537,12 @@ class PlaylistTab(QTableView):
header_row = self.get_base_model().is_header_row(model_row_number)
track_row = not header_row
if track_sequence.current:
this_is_current_row = model_row_number == track_sequence.current.row_number
if self.track_sequence.current:
this_is_current_row = model_row_number == self.track_sequence.current.row_number
else:
this_is_current_row = False
if track_sequence.next:
this_is_next_row = model_row_number == track_sequence.next.row_number
if self.track_sequence.next:
this_is_next_row = model_row_number == self.track_sequence.next.row_number
else:
this_is_next_row = False
track_path = base_model.get_row_info(model_row_number).path
@ -676,8 +678,6 @@ class PlaylistTab(QTableView):
Called when column width changes. Save new width to database.
"""
log.debug(f"_column_resize({column_number=}, {_old=}, {_new=}")
header = self.horizontalHeader()
if not header:
return
@ -722,6 +722,7 @@ class PlaylistTab(QTableView):
cb.clear(mode=cb.Mode.Clipboard)
cb.setText(track_path, mode=cb.Mode.Clipboard)
@log_call
def current_track_started(self) -> None:
"""
Called when track starts playing
@ -757,8 +758,8 @@ class PlaylistTab(QTableView):
# Don't delete current or next tracks
selected_row_numbers = self.selected_model_row_numbers()
for ts in [
track_sequence.next,
track_sequence.current,
self.track_sequence.next,
self.track_sequence.current,
]:
if ts:
if (
@ -809,6 +810,7 @@ class PlaylistTab(QTableView):
else:
return TrackInfo(track_id, selected_row)
@log_call
def get_selected_row(self) -> Optional[int]:
"""
Return selected row number. If no rows or multiple rows selected, return None
@ -820,6 +822,7 @@ class PlaylistTab(QTableView):
else:
return None
@log_call
def get_selected_rows(self) -> list[int]:
"""Return a list of model-selected row numbers sorted by row"""
@ -832,6 +835,7 @@ class PlaylistTab(QTableView):
return sorted(list(set([self.model().mapToSource(a).row() for a in selected_indexes])))
@log_call
def get_top_visible_row(self) -> int:
"""
Get the viewport of the table view
@ -954,8 +958,6 @@ class PlaylistTab(QTableView):
If playlist_id is us, resize rows
"""
log.debug(f"resize_rows({playlist_id=}) {self.playlist_id=}")
if playlist_id and playlist_id != self.playlist_id:
return
@ -1002,6 +1004,7 @@ class PlaylistTab(QTableView):
# Reset selection mode
self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
@log_call
def source_model_selected_row_number(self) -> Optional[int]:
"""
Return the model row number corresponding to the selected row or None
@ -1012,6 +1015,7 @@ class PlaylistTab(QTableView):
return None
return self.model().mapToSource(selected_index).row()
@log_call
def selected_model_row_numbers(self) -> list[int]:
"""
Return a list of model row numbers corresponding to the selected rows or
@ -1054,8 +1058,6 @@ class PlaylistTab(QTableView):
def _set_column_widths(self) -> None:
"""Column widths from settings"""
log.debug("_set_column_widths()")
header = self.horizontalHeader()
if not header:
return
@ -1119,7 +1121,7 @@ class PlaylistTab(QTableView):
# Update musicmuster
self.musicmuster.current.playlist_id = self.playlist_id
self.musicmuster.current.selected_rows = self.get_selected_rows()
self.musicmuster.current.selected_row_numbers = self.get_selected_rows()
self.musicmuster.current.base_model = self.get_base_model()
self.musicmuster.current.proxy_model = self.model()
@ -1128,6 +1130,6 @@ class PlaylistTab(QTableView):
def _unmark_as_next(self) -> None:
"""Rescan track"""
track_sequence.set_next(None)
self.track_sequence.set_next(None)
self.clear_selection()
self.signals.next_track_changed_signal.emit()

View File

@ -40,7 +40,7 @@ from helpers import (
)
from log import log
from models import db, Playdates, Tracks
from music_manager import RowAndTrack
from playlistrow import PlaylistRow
@dataclass
@ -268,7 +268,7 @@ class QuerylistModel(QAbstractTableModel):
bottom_right = self.index(row, self.columnCount() - 1)
self.dataChanged.emit(top_left, bottom_right, [Qt.ItemDataRole.BackgroundRole])
def _tooltip_role(self, row: int, column: int, rat: RowAndTrack) -> str | QVariant:
def _tooltip_role(self, row: int, column: int, rat: PlaylistRow) -> str | QVariant:
"""
Return tooltip. Currently only used for last_played column.
"""

748
app/repository.py Normal file
View File

@ -0,0 +1,748 @@
# Standard library imports
import re
# PyQt imports
# Third party imports
from sqlalchemy import (
delete,
func,
select,
update,
)
from sqlalchemy.orm import aliased
from sqlalchemy.orm.session import Session
from sqlalchemy.sql.elements import BinaryExpression, ColumnElement
from classes import ApplicationError, PlaylistRowDTO
# App imports
from classes import PlaylistDTO, TrackDTO
from config import Config
import helpers
from log import log
from models import (
db,
NoteColours,
Playdates,
PlaylistRows,
Playlists,
Settings,
Tracks,
)
# Notecolour functions
def get_colour(text: str, foreground: bool = False) -> str:
"""
Parse text and return background (foreground if foreground==True)
colour string if matched, else None
"""
if not text:
return ""
match = False
with db.Session() as session:
for rec in NoteColours.get_all(session):
if rec.is_regex:
flags = re.UNICODE
if not rec.is_casesensitive:
flags |= re.IGNORECASE
p = re.compile(rec.substring, flags)
if p.match(text):
match = True
else:
if rec.is_casesensitive:
if rec.substring in text:
match = True
else:
if rec.substring.lower() in text.lower():
match = True
if match:
if foreground:
return rec.foreground or ""
else:
return rec.colour
return ""
# Track functions
def add_track_to_header(playlistrow_id: int, track_id: int) -> None:
"""
Add a track to this (header) row
"""
with db.Session() as session:
session.execute(
update(PlaylistRows)
.where(PlaylistRows.id == playlistrow_id)
.values(track_id=track_id)
)
session.commit()
def create_track(path: str) -> TrackDTO:
"""
Create a track db entry from a track path and return the DTO
"""
metadata = helpers.get_all_track_metadata(path)
with db.Session() as session:
try:
track = Tracks(
session=session,
path=str(metadata["path"]),
title=str(metadata["title"]),
artist=str(metadata["artist"]),
duration=int(metadata["duration"]),
start_gap=int(metadata["start_gap"]),
fade_at=int(metadata["fade_at"]),
silence_at=int(metadata["silence_at"]),
bitrate=int(metadata["bitrate"]),
)
track_id = track.id
session.commit()
except Exception:
raise ApplicationError("Can't create Track")
new_track = track_by_id(track_id)
if not new_track:
raise ApplicationError("Unable to create new track")
return new_track
def get_all_tracks() -> list[TrackDTO]:
"""Return a list of all tracks"""
return _tracks_where(Tracks.id > 0)
def track_by_id(track_id: int) -> TrackDTO | None:
"""
Return track with specified id
"""
# Alias PlaydatesTable for subquery
LatestPlaydate = aliased(Playdates)
# Subquery: latest playdate for each track
latest_playdate_subq = (
select(
LatestPlaydate.track_id,
func.max(LatestPlaydate.lastplayed).label("lastplayed"),
)
.group_by(LatestPlaydate.track_id)
.subquery()
)
stmt = (
select(
Tracks.id.label("track_id"),
Tracks.artist,
Tracks.bitrate,
Tracks.duration,
Tracks.fade_at,
Tracks.intro,
Tracks.path,
Tracks.silence_at,
Tracks.start_gap,
Tracks.title,
latest_playdate_subq.c.lastplayed,
)
.outerjoin(latest_playdate_subq, Tracks.id == latest_playdate_subq.c.track_id)
.where(Tracks.id == track_id)
)
with db.Session() as session:
record = session.execute(stmt).one_or_none()
if not record:
return None
dto = TrackDTO(
artist=record.artist,
bitrate=record.bitrate,
duration=record.duration,
fade_at=record.fade_at,
intro=record.intro,
lastplayed=record.lastplayed,
path=record.path,
silence_at=record.silence_at,
start_gap=record.start_gap,
title=record.title,
track_id=record.track_id,
)
return dto
def _tracks_where(where: BinaryExpression | ColumnElement[bool]) -> list[TrackDTO]:
"""
Return tracks selected by where
"""
# Alias PlaydatesTable for subquery
LatestPlaydate = aliased(Playdates)
# Subquery: latest playdate for each track
latest_playdate_subq = (
select(
LatestPlaydate.track_id,
func.max(LatestPlaydate.lastplayed).label("lastplayed"),
)
.group_by(LatestPlaydate.track_id)
.subquery()
)
stmt = (
select(
Tracks.id.label("track_id"),
Tracks.artist,
Tracks.bitrate,
Tracks.duration,
Tracks.fade_at,
Tracks.intro,
Tracks.path,
Tracks.silence_at,
Tracks.start_gap,
Tracks.title,
latest_playdate_subq.c.lastplayed,
)
.outerjoin(latest_playdate_subq, Tracks.id == latest_playdate_subq.c.track_id)
.where(where)
)
results: list[TrackDTO] = []
with db.Session() as session:
records = session.execute(stmt).all()
for record in records:
dto = TrackDTO(
artist=record.artist,
bitrate=record.bitrate,
duration=record.duration,
fade_at=record.fade_at,
intro=record.intro,
lastplayed=record.lastplayed,
path=record.path,
silence_at=record.silence_at,
start_gap=record.start_gap,
title=record.title,
track_id=record.track_id,
)
results.append(dto)
return results
def tracks_like_artist(filter_str: str) -> list[TrackDTO]:
"""
Return tracks where artist is like filter
"""
return _tracks_where(Tracks.artist.ilike(f"%{filter_str}%"))
def tracks_like_title(filter_str: str) -> list[TrackDTO]:
"""
Return tracks where title is like filter
"""
return _tracks_where(Tracks.title.ilike(f"%{filter_str}%"))
# Playlist functions
def _check_playlist_integrity(
session: Session, playlist_id: int, fix: bool = False
) -> None:
"""
Ensure the row numbers are contiguous. Fix and log if fix==True,
else raise ApplicationError.
"""
playlist_rows = (
session.execute(
select(PlaylistRows)
.where(PlaylistRows.playlist_id == playlist_id)
.order_by(PlaylistRows.row_number)
)
.scalars()
.all()
)
for idx, plr in enumerate(playlist_rows):
if plr.row_number == idx:
continue
msg = (
"_check_playlist_integrity: incorrect row number "
f"({plr.id=}, {plr.row_number=}, {idx=})"
)
if fix:
log.debug(msg)
plr.row_number = idx
else:
raise ApplicationError(msg)
def _shift_rows(
session: Session, playlist_id: int, starting_row: int, shift_by: int
) -> None:
"""
Shift rows from starting_row by shift_by. If shift_by is +ve, shift rows
down; if -ve, shift them up.
"""
log.debug(f"(_shift_rows_down({playlist_id=}, {starting_row=}, {shift_by=}")
session.execute(
update(PlaylistRows)
.where(
(PlaylistRows.playlist_id == playlist_id),
(PlaylistRows.row_number >= starting_row),
)
.values(row_number=PlaylistRows.row_number + shift_by)
)
def move_rows(
from_rows: list[int], from_playlist_id: int, to_row: int, to_playlist_id: int | None = None
) -> None:
"""
Move rows with or between playlists.
Algorithm:
- Sanity check row numbers
- Check there are no playlist rows with playlist_id == PENDING_MOVE
- Put rows to be moved into PENDING_MOVE playlist
- Resequence remaining row numbers
- Make space for moved rows
- Move the PENDING_MOVE rows back and fixup row numbers
- Sanity check row numbers
"""
log.debug(
f"move_rows_to_playlist({from_rows=}, {from_playlist_id=}, {to_row=}, {to_playlist_id=})"
)
# If to_playlist_id isn't specified, we're moving within the one
# playlist.
if to_playlist_id is None:
to_playlist_id = from_playlist_id
with db.Session() as session:
# Sanity check row numbers
_check_playlist_integrity(session, from_playlist_id, fix=False)
if from_playlist_id != to_playlist_id:
_check_playlist_integrity(session, to_playlist_id, fix=False)
# Check there are no playlist rows with playlist_id == PENDING_MOVE
pending_move_rows = get_playlist_rows(Config.PLAYLIST_PENDING_MOVE)
if pending_move_rows:
raise ApplicationError(f"move_rows_to_playlist: {pending_move_rows=}")
# We need playlist length if we're moving within a playlist. Get
# that now before we remove rows.
from_playlist_length = len(get_playlist_rows(from_playlist_id))
# Put rows to be moved into PENDING_MOVE playlist
session.execute(
update(PlaylistRows)
.where(
PlaylistRows.playlist_id == from_playlist_id,
PlaylistRows.row_number.in_(from_rows),
)
.values(playlist_id=Config.PLAYLIST_PENDING_MOVE)
)
# Resequence remaining row numbers
_check_playlist_integrity(session, from_playlist_id, fix=True)
session.commit()
# Make space for moved rows. If moving within one playlist,
# determning where to make the space is non-trivial. For example,
# if the playlist has ten entries and we're moving four of them
# to row 8, after we've moved the rows to the
# PLAYLIST_PENDING_MOVE there will only be six entries left.
# Clearly we can't make space at row 8...
space_row = to_row
if to_playlist_id == from_playlist_id:
overflow = max(to_row + len(from_rows) - from_playlist_length, 0)
if overflow != 0:
space_row = (
to_row - overflow - len([a for a in from_rows if a > to_row])
)
_shift_rows(session, to_playlist_id, space_row, len(from_rows))
# Move the PENDING_MOVE rows back and fixup row numbers
update_list: list[dict[str, int]] = []
next_row = space_row
# PLAYLIST_PENDING_MOVE may have gaps so don't check it
for row_to_move in get_playlist_rows(
Config.PLAYLIST_PENDING_MOVE, check_playlist_itegrity=False
):
update_list.append(
{"id": row_to_move.playlistrow_id, "row_number": next_row}
)
update_list.append(
{"id": row_to_move.playlistrow_id, "playlist_id": to_playlist_id}
)
next_row += 1
session.execute(update(PlaylistRows), update_list)
session.commit()
# Sanity check row numbers
_check_playlist_integrity(session, from_playlist_id, fix=False)
if from_playlist_id != to_playlist_id:
_check_playlist_integrity(session, to_playlist_id, fix=False)
def update_row_numbers(
playlist_id: int, id_to_row_number: list[dict[int, int]]
) -> None:
"""
Update playlistrows rownumbers for passed playlistrow_ids
playlist_id is only needed for sanity checking
"""
with db.Session() as session:
session.execute(update(PlaylistRows), id_to_row_number)
session.commit()
# Sanity check
_check_playlist_integrity(session, playlist_id, fix=False)
def create_playlist(name: str, template_id: int) -> PlaylistDTO:
"""
Create playlist and return DTO.
"""
with db.Session() as session:
try:
playlist = Playlists(session, name, template_id)
playlist_id = playlist.id
session.commit()
except Exception:
raise ApplicationError("Can't create Playlist")
new_playlist = playlist_by_id(playlist_id)
if not new_playlist:
raise ApplicationError("Can't retrieve new Playlist")
return new_playlist
def get_playlist_row(playlistrow_id: int) -> PlaylistRowDTO | None:
"""
Return specific row DTO
"""
# Alias PlaydatesTable for subquery
LatestPlaydate = aliased(Playdates)
# Subquery: latest playdate for each track
latest_playdate_subq = (
select(
LatestPlaydate.track_id,
func.max(LatestPlaydate.lastplayed).label("lastplayed"),
)
.group_by(LatestPlaydate.track_id)
.subquery()
)
stmt = (
select(
PlaylistRows.id.label("playlistrow_id"),
PlaylistRows.row_number,
PlaylistRows.note,
PlaylistRows.played,
PlaylistRows.playlist_id,
Tracks.id.label("track_id"),
Tracks.artist,
Tracks.bitrate,
Tracks.duration,
Tracks.fade_at,
Tracks.intro,
Tracks.path,
Tracks.silence_at,
Tracks.start_gap,
Tracks.title,
latest_playdate_subq.c.lastplayed,
)
.outerjoin(Tracks, PlaylistRows.track_id == Tracks.id)
.outerjoin(latest_playdate_subq, Tracks.id == latest_playdate_subq.c.track_id)
.where(PlaylistRows.id == playlistrow_id)
.order_by(PlaylistRows.row_number)
)
with db.Session() as session:
record = session.execute(stmt).one_or_none()
if not record:
return None
# Handle cases where track_id is None (no track associated)
if record.track_id is None:
dto = PlaylistRowDTO(
artist="",
bitrate=0,
duration=0,
fade_at=0,
intro=None,
lastplayed=None,
note=record.note,
path="",
played=record.played,
playlist_id=record.playlist_id,
playlistrow_id=record.playlistrow_id,
row_number=record.row_number,
silence_at=0,
start_gap=0,
title="",
track_id=-1,
)
else:
dto = PlaylistRowDTO(
artist=record.artist,
bitrate=record.bitrate,
duration=record.duration,
fade_at=record.fade_at,
intro=record.intro,
lastplayed=record.lastplayed,
note=record.note,
path=record.path,
played=record.played,
playlist_id=record.playlist_id,
playlistrow_id=record.playlistrow_id,
row_number=record.row_number,
silence_at=record.silence_at,
start_gap=record.start_gap,
title=record.title,
track_id=record.track_id,
)
return dto
def get_playlist_rows(
playlist_id: int, check_playlist_itegrity=True
) -> list[PlaylistRowDTO]:
# Alias PlaydatesTable for subquery
LatestPlaydate = aliased(Playdates)
# Subquery: latest playdate for each track
latest_playdate_subq = (
select(
LatestPlaydate.track_id,
func.max(LatestPlaydate.lastplayed).label("lastplayed"),
)
.group_by(LatestPlaydate.track_id)
.subquery()
)
stmt = (
select(
PlaylistRows.id.label("playlistrow_id"),
PlaylistRows.row_number,
PlaylistRows.note,
PlaylistRows.played,
PlaylistRows.playlist_id,
Tracks.id.label("track_id"),
Tracks.artist,
Tracks.bitrate,
Tracks.duration,
Tracks.fade_at,
Tracks.intro,
Tracks.path,
Tracks.silence_at,
Tracks.start_gap,
Tracks.title,
latest_playdate_subq.c.lastplayed,
)
.outerjoin(Tracks, PlaylistRows.track_id == Tracks.id)
.outerjoin(latest_playdate_subq, Tracks.id == latest_playdate_subq.c.track_id)
.where(PlaylistRows.playlist_id == playlist_id)
.order_by(PlaylistRows.row_number)
)
with db.Session() as session:
results = session.execute(stmt).all()
# Sanity check
# TODO: would be good to be confident at removing this
if check_playlist_itegrity:
_check_playlist_integrity(
session=session, playlist_id=playlist_id, fix=False
)
dto_list = []
for row in results:
# Handle cases where track_id is None (no track associated)
if row.track_id is None:
dto = PlaylistRowDTO(
artist="",
bitrate=0,
duration=0,
fade_at=0,
intro=None,
lastplayed=None,
note=row.note,
path="",
played=row.played,
playlist_id=row.playlist_id,
playlistrow_id=row.playlistrow_id,
row_number=row.row_number,
silence_at=0,
start_gap=0,
title="",
track_id=-1,
# Additional fields like row_fg, row_bg, etc., use default None values
)
else:
dto = PlaylistRowDTO(
artist=row.artist,
bitrate=row.bitrate,
duration=row.duration,
fade_at=row.fade_at,
intro=row.intro,
lastplayed=row.lastplayed,
note=row.note,
path=row.path,
played=row.played,
playlist_id=row.playlist_id,
playlistrow_id=row.playlistrow_id,
row_number=row.row_number,
silence_at=row.silence_at,
start_gap=row.start_gap,
title=row.title,
track_id=row.track_id,
# Additional fields like row_fg, row_bg, etc., use default None values
)
dto_list.append(dto)
return dto_list
def insert_row(
playlist_id: int, row_number: int, track_id: int | None, note: str
) -> PlaylistRowDTO:
"""
Insert a new row into playlist and return new row DTO
"""
with db.Session() as session:
# Sanity check
_check_playlist_integrity(session, playlist_id, fix=False)
# Make space for new row
_shift_rows(
session=session,
playlist_id=playlist_id,
starting_row=row_number,
shift_by=1,
)
playlist_row = PlaylistRows.insert_row(
session=session,
playlist_id=playlist_id,
new_row_number=row_number,
note=note,
track_id=track_id,
)
session.commit()
playlist_row_id = playlist_row.id
# Sanity check
_check_playlist_integrity(session, playlist_id, fix=False)
new_playlist_row = get_playlist_row(playlistrow_id=playlist_row_id)
if not new_playlist_row:
raise ApplicationError("Can't retrieve new playlist row")
return new_playlist_row
def remove_rows(playlist_id: int, row_numbers: list[int]) -> None:
"""
Remove rows from playlist
Delete from highest row back so that not yet deleted row numbers don't change.
"""
log.debug(f"remove_rows({playlist_id=}, {row_numbers=}")
with db.Session() as session:
for row_number in sorted(row_numbers, reverse=True):
session.execute(
delete(PlaylistRows).where(
PlaylistRows.playlist_id == playlist_id,
PlaylistRows.row_number == row_number,
)
)
# Fixup row number to remove gaps
_check_playlist_integrity(session, playlist_id, fix=True)
session.commit()
def playlist_by_id(playlist_id: int) -> PlaylistDTO | None:
"""
Return playlist with specified id
"""
stmt = select(
Playlists.id.label("playlist_id"),
Playlists.name,
Playlists.favourite,
Playlists.is_template,
Playlists.open,
).where(Playlists.id == playlist_id)
with db.Session() as session:
record = session.execute(stmt).one_or_none()
if not record:
return None
dto = PlaylistDTO(
name=record.name,
playlist_id=record.playlist_id,
favourite=record.favourite,
is_template=record.is_template,
open=record.open,
)
return dto
# Misc
def get_setting(name: str) -> int | None:
"""
Get int setting
"""
with db.Session() as session:
record = session.execute(
select(Settings).where(Settings.name == name)
).one_or_none()
if not record:
return None
return record.f_int
def set_setting(name: str, value: int) -> None:
"""
Add int setting
"""
with db.Session() as session:
record = session.execute(
select(Settings).where(Settings.name == name)
).one_or_none()
if not record:
record = Settings(session=session, name=name)
if not record:
raise ApplicationError("Can't create Settings record")
record.f_int = value
session.commit()

View File

@ -1,29 +0,0 @@
# Standard library imports
# PyQt imports
# Third party imports
import vlc # type: ignore
# App imports
class VLCManager:
"""
Singleton class to ensure we only ever have one vlc Instance
"""
__instance = None
def __init__(self) -> None:
if VLCManager.__instance is None:
self.vlc_instance = vlc.Instance()
VLCManager.__instance = self
else:
raise Exception("Attempted to create a second VLCManager instance")
@staticmethod
def get_instance() -> vlc.Instance:
if VLCManager.__instance is None:
VLCManager()
return VLCManager.__instance

View File

@ -0,0 +1,40 @@
# Standard library imports
import unittest
# PyQt imports
# Third party imports
# App imports
from app.models import (
db,
)
class MyTestCase(unittest.TestCase):
@classmethod
def setUpClass(cls):
"""Runs once before any test in this class"""
pass
@classmethod
def tearDownClass(cls):
"""Runs once after all tests"""
pass
def setUp(self):
"""Runs before each test"""
db.create_all()
def tearDown(self):
"""Runs after each test"""
db.drop_all()
def test_xxx(self):
"""Comment"""
pass

View File

@ -134,122 +134,6 @@ class TestMMMiscRowMove(unittest.TestCase):
def tearDown(self):
db.drop_all()
def test_move_rows_test2(self):
# move row 3 to row 5
self.model.move_rows([3], 5)
# Check we have all rows and plr_rownums are correct
for row in range(self.model.rowCount()):
assert row in self.model.playlist_rows
assert self.model.playlist_rows[row].row_number == row
if row not in [3, 4, 5]:
assert self.model.playlist_rows[row].note == str(row)
elif row == 3:
assert self.model.playlist_rows[row].note == str(4)
elif row == 4:
assert self.model.playlist_rows[row].note == str(3)
elif row == 5:
assert self.model.playlist_rows[row].note == str(5)
def test_move_rows_test3(self):
# move row 4 to row 3
self.model.move_rows([4], 3)
# Check we have all rows and plr_rownums are correct
for row in range(self.model.rowCount()):
assert row in self.model.playlist_rows
assert self.model.playlist_rows[row].row_number == row
if row not in [3, 4]:
assert self.model.playlist_rows[row].note == str(row)
elif row == 3:
assert self.model.playlist_rows[row].note == str(4)
elif row == 4:
assert self.model.playlist_rows[row].note == str(3)
def test_move_rows_test4(self):
# move row 4 to row 2
self.model.move_rows([4], 2)
# Check we have all rows and plr_rownums are correct
for row in range(self.model.rowCount()):
assert row in self.model.playlist_rows
assert self.model.playlist_rows[row].row_number == row
if row not in [2, 3, 4]:
assert self.model.playlist_rows[row].note == str(row)
elif row == 2:
assert self.model.playlist_rows[row].note == str(4)
elif row == 3:
assert self.model.playlist_rows[row].note == str(2)
elif row == 4:
assert self.model.playlist_rows[row].note == str(3)
def test_move_rows_test5(self):
# move rows [1, 4, 5, 10] → 8
self.model.move_rows([1, 4, 5, 10], 8)
# Check we have all rows and plr_rownums are correct
new_order = []
for row in range(self.model.rowCount()):
assert row in self.model.playlist_rows
assert self.model.playlist_rows[row].row_number == row
new_order.append(int(self.model.playlist_rows[row].note))
assert new_order == [0, 2, 3, 6, 7, 1, 4, 5, 10, 8, 9]
def test_move_rows_test6(self):
# move rows [3, 6] → 5
self.model.move_rows([3, 6], 5)
# Check we have all rows and plr_rownums are correct
new_order = []
for row in range(self.model.rowCount()):
assert row in self.model.playlist_rows
assert self.model.playlist_rows[row].row_number == row
new_order.append(int(self.model.playlist_rows[row].note))
assert new_order == [0, 1, 2, 4, 3, 6, 5, 7, 8, 9, 10]
def test_move_rows_test7(self):
# move rows [3, 5, 6] → 8
self.model.move_rows([3, 5, 6], 8)
# Check we have all rows and plr_rownums are correct
new_order = []
for row in range(self.model.rowCount()):
assert row in self.model.playlist_rows
assert self.model.playlist_rows[row].row_number == row
new_order.append(int(self.model.playlist_rows[row].note))
assert new_order == [0, 1, 2, 4, 7, 3, 5, 6, 8, 9, 10]
def test_move_rows_test8(self):
# move rows [7, 8, 10] → 5
self.model.move_rows([7, 8, 10], 5)
# Check we have all rows and plr_rownums are correct
new_order = []
for row in range(self.model.rowCount()):
assert row in self.model.playlist_rows
assert self.model.playlist_rows[row].row_number == row
new_order.append(int(self.model.playlist_rows[row].note))
assert new_order == [0, 1, 2, 3, 4, 7, 8, 10, 5, 6, 9]
def test_move_rows_test9(self):
# move rows [1, 2, 3] → 0
# Replicate issue 244
self.model.move_rows([0, 1, 2, 3], 0)
# Check we have all rows and plr_rownums are correct
new_order = []
for row in range(self.model.rowCount()):
assert row in self.model.playlist_rows
assert self.model.playlist_rows[row].row_number == row
new_order.append(int(self.model.playlist_rows[row].note))
assert new_order == [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
def test_insert_header_row_end(self):
# insert header row at end of playlist

274
tests/test_repository.py Normal file
View File

@ -0,0 +1,274 @@
# Standard library imports
import unittest
# PyQt imports
# Third party imports
# App imports
from app import playlistmodel
from app import repository
from app.models import db
from classes import PlaylistDTO
from playlistmodel import PlaylistModel
class MyTestCase(unittest.TestCase):
@classmethod
def setUpClass(cls):
"""Runs once before any test in this class"""
cls.isa_path = "testdata/isa.mp3"
cls.isa_title = "I'm So Afraid"
cls.isa_artist = "Fleetwood Mac"
cls.mom_path = "testdata/mom.mp3"
cls.mom_title = "Man of Mystery"
cls.mom_artist = "The Shadows"
@classmethod
def tearDownClass(cls):
"""Runs once after all tests"""
pass
def setUp(self):
"""Runs before each test"""
db.create_all()
def create_playlist_and_model(
self, playlist_name: str
) -> (PlaylistDTO, PlaylistModel):
# Create a playlist and model
playlist = repository.create_playlist(name=playlist_name, template_id=0)
assert playlist
model = playlistmodel.PlaylistModel(playlist.playlist_id, is_template=False)
assert model
return (playlist, model)
def create_playlist_model_tracks(self, playlist_name: str):
(playlist, model) = self.create_playlist_and_model("my playlist")
# Create tracks
self.track1 = repository.create_track(self.isa_path)
self.track2 = repository.create_track(self.mom_path)
# Add tracks and header to playlist
self.row0 = repository.insert_row(
playlist.playlist_id,
row_number=0,
track_id=self.track1.track_id,
note="track 1",
)
self.row1 = repository.insert_row(
playlist.playlist_id,
row_number=1,
track_id=0,
note="Header row",
)
self.row2 = repository.insert_row(
playlist.playlist_id,
row_number=2,
track_id=self.track2.track_id,
note="track 2",
)
def create_rows(
self, playlist_name: str, number_of_rows: int
) -> (PlaylistDTO, PlaylistModel):
(playlist, model) = self.create_playlist_and_model(playlist_name)
for row_number in range(number_of_rows):
repository.insert_row(
playlist.playlist_id, row_number, None, str(row_number)
)
return (playlist, model)
def tearDown(self):
"""Runs after each test"""
db.drop_all()
def test_add_track_to_header(self):
"""Add a track to a header row"""
self.create_playlist_model_tracks("my playlist")
repository.add_track_to_header(self.row1.playlistrow_id, self.track2.track_id)
result = repository.get_playlist_row(self.row1.playlistrow_id)
assert result.track_id == self.track2.track_id
def test_create_track(self):
repository.create_track(self.isa_path)
results = repository.get_all_tracks()
assert len(results) == 1
assert results[0].path == self.isa_path
def test_get_track_by_id(self):
dto = repository.create_track(self.isa_path)
result = repository.track_by_id(dto.track_id)
assert result.path == self.isa_path
def test_get_track_by_artist(self):
_ = repository.create_track(self.isa_path)
_ = repository.create_track(self.mom_path)
result_isa = repository.tracks_like_artist(self.isa_artist)
assert len(result_isa) == 1
assert result_isa[0].artist == self.isa_artist
result_mom = repository.tracks_like_artist(self.mom_artist)
assert len(result_mom) == 1
assert result_mom[0].artist == self.mom_artist
def test_get_track_by_title(self):
_ = repository.create_track(self.isa_path)
_ = repository.create_track(self.mom_path)
result_isa = repository.tracks_like_title(self.isa_title)
assert len(result_isa) == 1
assert result_isa[0].title == self.isa_title
result_mom = repository.tracks_like_title(self.mom_title)
assert len(result_mom) == 1
assert result_mom[0].title == self.mom_title
def test_move_rows_test1(self):
# move row 3 to row 5
number_of_rows = 10
(playlist, model) = self.create_rows("test_move_rows_test1", number_of_rows)
repository.move_rows([3], playlist.playlist_id, 5)
# Check we have all rows and plr_rownums are correct
new_order = []
for row in repository.get_playlist_rows(playlist.playlist_id):
new_order.append(int(row.note))
assert new_order == [0, 1, 2, 4, 5, 3, 6, 7, 8, 9]
def test_move_rows_test2(self):
# move row 4 to row 3
number_of_rows = 10
(playlist, model) = self.create_rows("test_move_rows_test2", number_of_rows)
repository.move_rows([4], playlist.playlist_id, 3)
# Check we have all rows and plr_rownums are correct
new_order = []
for row in repository.get_playlist_rows(playlist.playlist_id):
new_order.append(int(row.note))
assert new_order == [0, 1, 2, 4, 3, 5, 6, 7, 8, 9]
def test_move_rows_test3(self):
# move row 4 to row 2
number_of_rows = 10
(playlist, model) = self.create_rows("test_move_rows_test3", number_of_rows)
repository.move_rows([4], playlist.playlist_id, 2)
# Check we have all rows and plr_rownums are correct
new_order = []
for row in repository.get_playlist_rows(playlist.playlist_id):
new_order.append(int(row.note))
assert new_order == [0, 1, 4, 2, 3, 5, 6, 7, 8, 9]
def test_move_rows_test4(self):
# move rows [1, 4, 5, 10] → 8
number_of_rows = 11
(playlist, model) = self.create_rows("test_move_rows_test4", number_of_rows)
repository.move_rows([1, 4, 5, 10], playlist.playlist_id, 8)
# Check we have all rows and plr_rownums are correct
new_order = []
for row in repository.get_playlist_rows(playlist.playlist_id):
new_order.append(int(row.note))
assert new_order == [0, 2, 3, 6, 7, 8, 1, 4, 5, 10, 9]
def test_move_rows_test5(self):
# move rows [3, 6] → 5
number_of_rows = 11
(playlist, model) = self.create_rows("test_move_rows_test5", number_of_rows)
repository.move_rows([3, 6], playlist.playlist_id, 5)
# Check we have all rows and plr_rownums are correct
new_order = []
for row in repository.get_playlist_rows(playlist.playlist_id):
new_order.append(int(row.note))
assert new_order == [0, 1, 2, 4, 5, 3, 6, 7, 8, 9, 10]
def test_move_rows_test6(self):
# move rows [3, 5, 6] → 8
number_of_rows = 11
(playlist, model) = self.create_rows("test_move_rows_test6", number_of_rows)
repository.move_rows([3, 5, 6], playlist.playlist_id, 8)
# Check we have all rows and plr_rownums are correct
new_order = []
for row in repository.get_playlist_rows(playlist.playlist_id):
new_order.append(int(row.note))
assert new_order == [0, 1, 2, 4, 7, 8, 9, 10, 3, 5, 6]
def test_move_rows_test7(self):
# move rows [7, 8, 10] → 5
number_of_rows = 11
(playlist, model) = self.create_rows("test_move_rows_test7", number_of_rows)
repository.move_rows([7, 8, 10], playlist.playlist_id, 5)
# Check we have all rows and plr_rownums are correct
new_order = []
for row in repository.get_playlist_rows(playlist.playlist_id):
new_order.append(int(row.note))
assert new_order == [0, 1, 2, 3, 4, 7, 8, 10, 5, 6, 9]
def test_move_rows_test8(self):
# move rows [1, 2, 3] → 0
# Replicate issue 244
number_of_rows = 11
(playlist, model) = self.create_rows("test_move_rows_test8", number_of_rows)
repository.move_rows([0, 1, 2, 3], playlist.playlist_id, 0)
# Check we have all rows and plr_rownums are correct
new_order = []
for row in repository.get_playlist_rows(playlist.playlist_id):
new_order.append(int(row.note))
assert new_order == [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
def test_move_rows_to_playlist(self):
number_of_rows = 11
rows_to_move = [2, 4, 6]
to_row = 5
(playlist_src, model_src) = self.create_rows("src playlist", number_of_rows)
(playlist_dst, model_dst) = self.create_rows("dst playlist", number_of_rows)
repository.move_rows(
rows_to_move, playlist_src.playlist_id, to_row, playlist_dst.playlist_id
)
# Check we have all rows and plr_rownums are correct
new_order_src = []
for row in repository.get_playlist_rows(playlist_src.playlist_id):
new_order_src.append(int(row.note))
assert new_order_src == [0, 1, 3, 5, 7, 8, 9, 10]
new_order_dst = []
for row in repository.get_playlist_rows(playlist_dst.playlist_id):
new_order_dst.append(int(row.note))
assert new_order_dst == [0, 1, 2, 3, 4, 2, 4, 6, 5, 6, 7, 8, 9, 10]
def test_remove_rows(self):
pass
def test_get_playlist_by_id(self):
pass
def test_settings(self):
pass