WIP: Can play tracks without errors

This commit is contained in:
Keith Edmunds 2025-03-21 12:10:46 +00:00
parent 7361086da5
commit a95aa918b1
13 changed files with 1134 additions and 1065 deletions

View File

@ -21,7 +21,6 @@ from PyQt6.QtWidgets import (
)
# App imports
# from music_manager import FadeCurve
# Define singleton first as it's needed below
@ -163,6 +162,14 @@ class TrackInfo(NamedTuple):
row_number: int
# Classes for signals
@dataclass
class InsertTrack:
playlist_id: int
track_id: int | None
note: str
@singleton
@dataclass
class MusicMusterSignals(QObject):
@ -181,9 +188,13 @@ class MusicMusterSignals(QObject):
search_wikipedia_signal = pyqtSignal(str)
show_warning_signal = pyqtSignal(str, str)
signal_add_track_to_header = pyqtSignal(int, int)
signal_insert_track = pyqtSignal(InsertTrack)
signal_playlist_selected_rows = pyqtSignal(int, list)
signal_set_next_row = pyqtSignal(int)
# TODO: undestirable (and unresolvable) reference
# signal_set_next_track = pyqtSignal(PlaylistRow)
# 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()

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

@ -41,7 +41,7 @@ from helpers import (
)
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
@ -701,6 +701,7 @@ class PickMatch(QDialog):
self.setWindowTitle("New or replace")
layout = QVBoxLayout()
track_sequence = TrackSequence()
# Add instructions
instructions = (

View File

@ -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

@ -3,32 +3,23 @@ from __future__ import annotations
import datetime as dt
from time import sleep
from typing import Any, 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 repository import PlaylistRowDTO
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,
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: 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()
@ -300,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.
@ -317,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(
@ -341,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"""
@ -381,370 +283,3 @@ class _Music:
self.player.stop()
self.player.release()
self.player = None
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: 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 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
"""
# TODO: only seems to be used by track_sequence
return
# 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[PlaylistRow] = None
current: Optional[PlaylistRow] = None
previous: Optional[PlaylistRow] = None
def set_next(self, rat: Optional[PlaylistRow]) -> 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.create_fade_graph()
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()
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 models import db, Playdates, PlaylistRows, Playlists, Queries, Settings, Tracks
from music_manager import PlaylistRow, 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}>"
)
@ -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"
@ -1671,7 +1672,7 @@ 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:
@ -1704,8 +1705,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(
@ -1714,8 +1715,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(
@ -1777,8 +1778,8 @@ 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):
@ -1816,8 +1817,8 @@ 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())
def enable_escape(self, enabled: bool) -> None:
"""
@ -1843,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()
@ -1915,8 +1911,8 @@ class Window(QMainWindow):
def fade(self) -> 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]:
"""
@ -1976,15 +1972,11 @@ class Window(QMainWindow):
def insert_track(self) -> 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()
def load_last_playlists(self) -> None:
"""Load the playlists that were open when the last session closed"""
@ -2038,7 +2030,7 @@ class Window(QMainWindow):
# Save the selected PlaylistRows items ready for a later
# paste
self.move_source_rows = self.current.selected_rows
self.move_source_rows = self.current.selected_row_numbers
self.move_source_model = self.current.base_model
log.debug(
@ -2080,21 +2072,14 @@ 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:
"""
Move selected rows to another playlist
"""
selected_rows = self.current.selected_rows
selected_rows = self.current.selected_row_numbers
if not selected_rows:
return
@ -2147,9 +2132,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
@ -2185,7 +2170,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
@ -2202,35 +2187,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
@ -2263,10 +2247,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
@ -2384,12 +2368,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()
):
@ -2398,8 +2382,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
)
@ -2428,18 +2412,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)
@ -2448,15 +2432,15 @@ 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:
"""Show text box to search playlist"""
@ -2491,12 +2475,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
@ -2540,8 +2524,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:
"""
@ -2554,8 +2538,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:
"""
@ -2594,8 +2578,8 @@ class Window(QMainWindow):
"""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"""
@ -2607,22 +2591,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}"
@ -2682,17 +2666,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)
)
@ -2741,25 +2725,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("")
@ -2774,25 +2758,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)

View File

@ -48,7 +48,7 @@ from helpers import (
)
from log import log
from models import db, NoteColours, Playdates, PlaylistRows, Tracks
from music_manager import PlaylistRow, track_sequence
from playlistrow import PlaylistRow, TrackSequence
import repository
@ -83,6 +83,7 @@ class PlaylistModel(QAbstractTableModel):
self.playlist_id = playlist_id
self.is_template = is_template
self.track_sequence = TrackSequence()
self.playlist_rows: dict[int, PlaylistRow] = {}
self.selected_rows: list[PlaylistRow] = []
@ -93,13 +94,16 @@ class PlaylistModel(QAbstractTableModel):
self.signals.begin_reset_model_signal.connect(self.begin_reset_model)
self.signals.end_reset_model_signal.connect(self.end_reset_model)
self.signals.signal_add_track_to_header.connect(self.add_track_to_header)
self.signals.signal_playlist_selected_rows.connect(self.set_selected_rows)
self.signals.signal_set_next_row.connect(self.set_next_row)
with db.Session() as session:
# Ensure row numbers in playlist are contiguous
# TODO: remove this
PlaylistRows.fixup_rownumbers(session, playlist_id)
# Populate self.playlist_rows
self.load_data(session)
# Populate self.playlist_rows
self.load_data()
self.update_track_times()
def __repr__(self) -> str:
@ -131,7 +135,7 @@ class PlaylistModel(QAbstractTableModel):
# playing it. It's also possible that the track marked as
# next has already been played. Check for either of those.
for ts in [track_sequence.next, track_sequence.current]:
for ts in [self.track_sequence.next, self.track_sequence.current]:
if (
ts
and ts.row_number == row_number
@ -178,14 +182,14 @@ class PlaylistModel(QAbstractTableModel):
# Update local copy
self.refresh_row(selected_row.row_number)
# Repaint row
roles = [
roles_to_invalidate = [
Qt.ItemDataRole.BackgroundRole,
Qt.ItemDataRole.DisplayRole,
Qt.ItemDataRole.FontRole,
Qt.ItemDataRole.ForegroundRole,
]
# only invalidate required roles
self.invalidate_row(row_number, roles)
self.invalidate_row(selected_row.row_number, roles_to_invalidate)
self.signals.resize_rows_signal.emit(self.playlist_id)
@ -207,16 +211,16 @@ class PlaylistModel(QAbstractTableModel):
return QBrush(QColor(Config.COLOUR_UNREADABLE))
# Current track
if (
track_sequence.current
and track_sequence.current.playlist_id == self.playlist_id
and track_sequence.current.row_number == row
self.track_sequence.current
and self.track_sequence.current.playlist_id == self.playlist_id
and self.track_sequence.current.row_number == row
):
return QBrush(QColor(Config.COLOUR_CURRENT_PLAYLIST))
# Next track
if (
track_sequence.next
and track_sequence.next.playlist_id == self.playlist_id
and track_sequence.next.row_number == row
self.track_sequence.next
and self.track_sequence.next.playlist_id == self.playlist_id
and self.track_sequence.next.row_number == row
):
return QBrush(QColor(Config.COLOUR_NEXT_PLAYLIST))
@ -271,66 +275,69 @@ class PlaylistModel(QAbstractTableModel):
log.debug(f"{self}: current_track_started()")
if not track_sequence.current:
if not self.track_sequence.current:
return
row_number = track_sequence.current.row_number
row_number = self.track_sequence.current.row_number
playlist_dto = self.playlist_rows[row_number]
# Check for OBS scene change
self.obs_scene_change(row_number)
# Sanity check that we have a track_id
track_id = track_sequence.current.track_id
track_id = playlist_dto.track_id
if not track_id:
raise ApplicationError(
f"{self}: current_track_started() called with {track_id=}"
f"current_track_started() called with no track_id ({playlist_dto=})"
)
with db.Session() as session:
# Update Playdates in database
log.debug(f"{self}: update playdates {track_id=}")
Playdates(session, track_id)
session.commit()
# TODO: ensure Playdates is updated
# with db.Session() as session:
# # Update Playdates in database
# log.debug(f"{self}: update playdates {track_id=}")
# Playdates(session, track_id)
# session.commit()
# Mark track as played in playlist
log.debug(f"{self}: Mark track as played")
plr = session.get(PlaylistRows, track_sequence.current.playlistrow_id)
if plr:
plr.played = True
self.refresh_row(session, plr.row_number)
else:
log.error(
f"{self}: Can't retrieve plr, {track_sequence.current.playlistrow_id=}"
)
# Mark track as played in playlist
playlist_dto.played = True
# Update colour and times for current row
# Update colour and times for current row
roles_to_invalidate = [Qt.ItemDataRole.DisplayRole]
self.invalidate_row(row_number, roles_to_invalidate)
# Update previous row in case we're hiding played rows
if self.track_sequence.previous and self.track_sequence.previous.row_number:
# only invalidate required roles
roles = [Qt.ItemDataRole.DisplayRole]
self.invalidate_row(row_number, roles)
self.invalidate_row(self.track_sequence.previous.row_number, roles_to_invalidate)
# Update previous row in case we're hiding played rows
if track_sequence.previous and track_sequence.previous.row_number:
# only invalidate required roles
self.invalidate_row(track_sequence.previous.row_number, roles)
# Update all other track times
self.update_track_times()
# Update all other track times
self.update_track_times()
# Find next track
next_row = self.find_next_row_to_play(row_number)
if next_row:
self.signals.signal_set_next_track.emit(self.playlist_rows[next_row])
# Find next track
next_row = None
unplayed_rows = [
a
for a in self.get_unplayed_rows()
if not self.is_header_row(a)
and not file_is_unreadable(self.playlist_rows[a].path)
]
if unplayed_rows:
try:
next_row = min([a for a in unplayed_rows if a > row_number])
except ValueError:
next_row = min(unplayed_rows)
if next_row is not None:
self.set_next_row(next_row)
def find_next_row_to_play(self, from_row_number: int) -> int | None:
"""
Find the next row to play in this playlist. Return row number or
None if there's no next track.
"""
next_row = None
unplayed_rows = [
a
for a in self.get_unplayed_rows()
if not self.is_header_row(a)
and not file_is_unreadable(self.playlist_rows[a].path)
]
if unplayed_rows:
try:
next_row = min([a for a in unplayed_rows if a > from_row_number])
except ValueError:
next_row = min(unplayed_rows)
return next_row
def data(
self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole
@ -401,7 +408,7 @@ class PlaylistModel(QAbstractTableModel):
session.commit()
super().endRemoveRows()
self.reset_track_sequence_row_numbers()
self.track_sequence.update()
self.update_track_times()
def _display_role(self, row: int, column: int, rat: PlaylistRow) -> str:
@ -477,7 +484,8 @@ class PlaylistModel(QAbstractTableModel):
with db.Session() as session:
self.refresh_data(session)
super().endResetModel()
self.reset_track_sequence_row_numbers()
self.track_sequence.update()
self.update_track_times()
def _edit_role(self, row: int, column: int, rat: PlaylistRow) -> str | int:
"""
@ -742,16 +750,17 @@ class PlaylistModel(QAbstractTableModel):
note=note,
)
# Insert into self.playlist_rows
# Move rows down to make room
for destination_row in range(len(self.playlist_rows), new_row_number, -1):
self.playlist_rows[destination_row] = self.playlist_rows[destination_row - 1]
self.playlist_rows[new_row_number] = new_row
# Insert into self.playlist_rows
self.playlist_rows[new_row_number] = PlaylistRow(new_row)
super().endInsertRows()
self.signals.resize_rows_signal.emit(self.playlist_id)
# TODO check this what we want to do and how we want to do it
self.reset_track_sequence_row_numbers()
self.track_sequence.update()
self.update_track_times()
roles_to_invalidate = [
Qt.ItemDataRole.BackgroundRole,
Qt.ItemDataRole.DisplayRole,
@ -816,7 +825,7 @@ class PlaylistModel(QAbstractTableModel):
return None
def load_data(self, session: Session) -> None:
def load_data(self) -> None:
"""
Same as refresh data, but only used when creating playslit.
Distinguishes profile time between initial load and other
@ -843,8 +852,8 @@ class PlaylistModel(QAbstractTableModel):
# build a new playlist_rows
# shouldn't be PlaylistRow
new_playlist_rows: dict[int, PlaylistRow] = {}
for p in repository.get_playlist_rows(self.playlist_id):
new_playlist_rows[p.row_number] = PlaylistRow(p)
for dto in repository.get_playlist_rows(self.playlist_id):
new_playlist_rows[dto.row_number] = PlaylistRow(dto)
# Copy to self.playlist_rows
self.playlist_rows = new_playlist_rows
@ -854,16 +863,9 @@ class PlaylistModel(QAbstractTableModel):
Mark row as unplayed
"""
with db.Session() as session:
for row_number in row_numbers:
playlist_row = session.get(
PlaylistRows, self.playlist_rows[row_number].playlistrow_id
)
if not playlist_row:
return
playlist_row.played = False
session.commit()
self.refresh_row(session, row_number)
for row_number in row_numbers:
self.playlist_rows[row_number].played = False
self.refresh_row(row_number)
self.update_track_times()
# only invalidate required roles
@ -933,7 +935,7 @@ class PlaylistModel(QAbstractTableModel):
self.refresh_data(session)
# Update display
self.reset_track_sequence_row_numbers()
self.track_sequence.update()
self.update_track_times()
# only invalidate required roles
roles = [
@ -987,8 +989,8 @@ class PlaylistModel(QAbstractTableModel):
[self.playlist_rows[a].playlistrow_id for a in row_group],
):
if (
track_sequence.current
and playlist_row.id == track_sequence.current.playlistrow_id
self.track_sequence.current
and playlist_row.id == self.track_sequence.current.playlistrow_id
):
# Don't move current track
continue
@ -1004,35 +1006,32 @@ class PlaylistModel(QAbstractTableModel):
session.commit()
# Reset of model must come after session has been closed
self.reset_track_sequence_row_numbers()
self.track_sequence.update()
self.signals.end_reset_model_signal.emit(to_playlist_id)
self.update_track_times()
def move_track_add_note(
self, new_row_number: int, existing_rat: PlaylistRow, note: str
self, new_row_number: int, existing_plr: PlaylistRow, note: str
) -> None:
"""
Move existing_rat track to new_row_number and append note to any existing note
"""
log.debug(
f"{self}: move_track_add_note({new_row_number=}, {existing_rat=}, {note=}"
f"{self}: move_track_add_note({new_row_number=}, {existing_plr=}, {note=}"
)
if note:
with db.Session() as session:
playlist_row = session.get(PlaylistRows, existing_rat.playlistrow_id)
if playlist_row:
if playlist_row.note:
playlist_row.note += "\n" + note
else:
playlist_row.note = note
self.refresh_row(session, playlist_row.row_number)
session.commit()
playlist_row = self.playlist_rows[existing_plr.row_number]
if playlist_row.note:
playlist_row.note += "\n" + note
else:
playlist_row.note = note
self.refresh_row(existing_plr.row_number)
# Carry out the move outside of the session context to ensure
# database updated with any note change
self.move_rows([existing_rat.row_number], new_row_number)
self.move_rows([existing_plr.row_number], new_row_number)
self.signals.resize_rows_signal.emit(self.playlist_id)
def obs_scene_change(self, row_number: int) -> None:
@ -1083,15 +1082,15 @@ class PlaylistModel(QAbstractTableModel):
log.debug(f"{self}: previous_track_ended()")
# Sanity check
if not track_sequence.previous:
if not self.track_sequence.previous:
log.error(
f"{self}: playlistmodel:previous_track_ended called with no current track"
)
return
if track_sequence.previous.row_number is None:
if self.track_sequence.previous.row_number is None:
log.error(
f"{self}: previous_track_ended called with no row number "
f"({track_sequence.previous=})"
f"({self.track_sequence.previous=})"
)
return
@ -1100,7 +1099,7 @@ class PlaylistModel(QAbstractTableModel):
roles = [
Qt.ItemDataRole.BackgroundRole,
]
self.invalidate_row(track_sequence.previous.row_number, roles)
self.invalidate_row(self.track_sequence.previous.row_number, roles)
def refresh_data(self, session: Session) -> None:
"""
@ -1123,21 +1122,25 @@ class PlaylistModel(QAbstractTableModel):
# build a new playlist_rows
new_playlist_rows: dict[int, PlaylistRow] = {}
for p in repository.get_playlist_rows(self.playlist_id):
if p.playlistrow_id not in plrid_to_row:
new_playlist_rows[p.row_number] = PlaylistRow(p)
for dto in repository.get_playlist_rows(self.playlist_id):
if dto.playlistrow_id not in plrid_to_row:
new_playlist_rows[dto.row_number] = PlaylistRow(dto)
else:
new_playlist_row = self.playlist_rows[plrid_to_row[p.playlistrow_id]]
new_playlist_row.row_number = p.row_number
new_playlist_row = self.playlist_rows[plrid_to_row[dto.playlistrow_id]]
new_playlist_row.row_number = dto.row_number
# Copy to self.playlist_rows
self.playlist_rows = new_playlist_rows
def refresh_row(self, session, row_number):
def refresh_row(self, row_number: int) -> None:
"""Populate dict for one row from database"""
p = PlaylistRows.deep_row(session, self.playlist_id, row_number)
self.playlist_rows[row_number] = PlaylistRow(p)
plrid = self.playlist_rows[row_number].playlistrow_id
refreshed_row = repository.get_playlist_row(plrid)
if not refreshed_row:
raise ApplicationError(f"Failed to retrieve row {self.playlist_id=}, {row_number=}")
self.playlist_rows[row_number] = PlaylistRow(refreshed_row)
def remove_track(self, row_number: int) -> None:
"""
@ -1146,14 +1149,8 @@ class PlaylistModel(QAbstractTableModel):
log.debug(f"{self}: remove_track({row_number=})")
with db.Session() as session:
playlist_row = session.get(
PlaylistRows, self.playlist_rows[row_number].playlistrow_id
)
if playlist_row:
playlist_row.track_id = None
session.commit()
self.refresh_row(session, row_number)
self.playlist_rows[row_number].track_id = None
# only invalidate required roles
roles = [
Qt.ItemDataRole.DisplayRole,
@ -1170,7 +1167,7 @@ class PlaylistModel(QAbstractTableModel):
with db.Session() as session:
track = session.get(Tracks, track_id)
set_track_metadata(track)
self.refresh_row(session, row_number)
self.refresh_row(row_number)
self.update_track_times()
roles = [
Qt.ItemDataRole.BackgroundRole,
@ -1181,32 +1178,6 @@ class PlaylistModel(QAbstractTableModel):
self.signals.resize_rows_signal.emit(self.playlist_id)
session.commit()
def reset_track_sequence_row_numbers(self) -> None:
"""
Signal handler for when row ordering has changed.
Example: row 4 is marked as next. Row 2 is deleted. The PlaylistRows table will
be correctly updated with change of row number, but track_sequence.next will still
contain row_number==4. This function fixes up the track_sequence row numbers by
looking up the playlistrow_id and retrieving the row number from the database.
"""
log.debug(f"issue285: {self}: reset_track_sequence_row_numbers()")
# Check the track_sequence.next, current and previous plrs and
# update the row number
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)
session.commit()
self.update_track_times()
def remove_comments(self, row_numbers: list[int]) -> None:
"""
Remove comments from passed rows
@ -1349,16 +1320,16 @@ class PlaylistModel(QAbstractTableModel):
# calculate end time when all tracks are played.
end_time_str = ""
if (
track_sequence.current
and track_sequence.current.end_time
self.track_sequence.current
and self.track_sequence.current.end_time
and (
row_number
< track_sequence.current.row_number
< self.track_sequence.current.row_number
< rat.row_number
)
):
section_end_time = (
track_sequence.current.end_time
self.track_sequence.current.end_time
+ dt.timedelta(milliseconds=duration)
)
end_time_str = (
@ -1411,12 +1382,16 @@ class PlaylistModel(QAbstractTableModel):
return True
def set_selected_rows(self, selected_rows: list[int]) -> None:
def set_selected_rows(self, playlist_id: int, selected_row_numbers: list[int]) -> None:
"""
Keep track of which rows are selected in the view
Handle signal_playlist_selected_rows to keep track of which rows
are selected in the view
"""
self.selected_rows = [self.playlist_rows[a] for a in selected_rows]
if playlist_id != self.playlist_id:
return
self.selected_rows = [self.playlist_rows[a] for a in selected_row_numbers]
def set_next_row(self, playlist_id: int) -> None:
"""
@ -1430,8 +1405,8 @@ class PlaylistModel(QAbstractTableModel):
if len(self.selected_rows) == 0:
# No row selected so clear next track
if track_sequence.next is not None:
track_sequence.set_next(None)
if self.track_sequence.next is not None:
self.track_sequence.set_next(None)
return
if len(self.selected_rows) > 1:
@ -1445,10 +1420,10 @@ class PlaylistModel(QAbstractTableModel):
raise ApplicationError(f"set_next_row: no track_id ({rat=})")
old_next_row: Optional[int] = None
if track_sequence.next:
old_next_row = track_sequence.next.row_number
if self.track_sequence.next:
old_next_row = self.track_sequence.next.row_number
track_sequence.set_next(rat)
self.track_sequence.set_next(rat)
roles = [
Qt.ItemDataRole.BackgroundRole,
@ -1513,7 +1488,7 @@ class PlaylistModel(QAbstractTableModel):
# commit changes before refreshing data
session.commit()
self.refresh_row(session, row_number)
self.refresh_row(row_number)
self.dataChanged.emit(index, index, [Qt.ItemDataRole.DisplayRole, role])
return True
@ -1636,9 +1611,8 @@ class PlaylistModel(QAbstractTableModel):
a.row_number for a in self.playlist_rows.values() if a.track_id == track_id
]
if track_rows:
with db.Session() as session:
for row in track_rows:
self.refresh_row(session, row)
for row in track_rows:
self.refresh_row(row)
# only invalidate required roles
roles = [
Qt.ItemDataRole.BackgroundRole,
@ -1664,19 +1638,19 @@ class PlaylistModel(QAbstractTableModel):
current_track_row = None
next_track_row = None
if (
track_sequence.current
and track_sequence.current.playlist_id == self.playlist_id
self.track_sequence.current
and self.track_sequence.current.playlist_id == self.playlist_id
):
current_track_row = track_sequence.current.row_number
current_track_row = self.track_sequence.current.row_number
# Update current track details now so that they are available
# when we deal with next track row which may be above current
# track row.
self.playlist_rows[current_track_row].set_forecast_start_time(
update_rows, track_sequence.current.start_time
update_rows, self.track_sequence.current.start_time
)
if track_sequence.next and track_sequence.next.playlist_id == self.playlist_id:
next_track_row = track_sequence.next.row_number
if self.track_sequence.next and self.track_sequence.next.playlist_id == self.playlist_id:
next_track_row = self.track_sequence.next.row_number
for row_number in range(row_count):
rat = self.playlist_rows[row_number]
@ -1700,11 +1674,11 @@ class PlaylistModel(QAbstractTableModel):
# Set start time for next row if we have a current track
if (
row_number == next_track_row
and track_sequence.current
and track_sequence.current.end_time
and self.track_sequence.current
and self.track_sequence.current.end_time
):
next_start_time = rat.set_forecast_start_time(
update_rows, track_sequence.current.end_time
update_rows, self.track_sequence.current.end_time
)
continue
@ -1738,6 +1712,8 @@ class PlaylistProxyModel(QSortFilterProxyModel):
# Search all columns
self.setFilterKeyColumn(-1)
self.track_sequence = TrackSequence()
def __repr__(self) -> str:
return f"<PlaylistProxyModel: sourceModel={self.sourceModel()}>"
@ -1753,37 +1729,37 @@ class PlaylistProxyModel(QSortFilterProxyModel):
if self.sourceModel().is_played_row(source_row):
# Don't hide current track
if (
track_sequence.current
and track_sequence.current.playlist_id
self.track_sequence.current
and self.track_sequence.current.playlist_id
== self.sourceModel().playlist_id
and track_sequence.current.row_number == source_row
and self.track_sequence.current.row_number == source_row
):
return True
# Don't hide next track
if (
track_sequence.next
and track_sequence.next.playlist_id
self.track_sequence.next
and self.track_sequence.next.playlist_id
== self.sourceModel().playlist_id
and track_sequence.next.row_number == source_row
and self.track_sequence.next.row_number == source_row
):
return True
# Handle previous track
if track_sequence.previous:
if self.track_sequence.previous:
if (
track_sequence.previous.playlist_id
self.track_sequence.previous.playlist_id
!= self.sourceModel().playlist_id
or track_sequence.previous.row_number != source_row
or self.track_sequence.previous.row_number != source_row
):
# This row isn't our previous track: hide it
return False
if track_sequence.current and track_sequence.current.start_time:
if self.track_sequence.current and self.track_sequence.current.start_time:
# This row is our previous track. Don't hide it
# until HIDE_AFTER_PLAYING_OFFSET milliseconds
# after current track has started
if track_sequence.current.start_time and dt.datetime.now() > (
track_sequence.current.start_time
if self.track_sequence.current.start_time and dt.datetime.now() > (
self.track_sequence.current.start_time
+ dt.timedelta(
milliseconds=Config.HIDE_AFTER_PLAYING_OFFSET
)

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,7 +37,7 @@ 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,
@ -46,7 +46,7 @@ from helpers import (
)
from log import log
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()))
@ -408,8 +409,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
@ -461,12 +462,14 @@ class PlaylistTab(QTableView):
Toggle drag behaviour according to whether rows are selected
"""
selected_rows = self.get_selected_rows()
self.musicmuster.current.selected_rows = selected_rows
self.get_base_model().set_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:
@ -517,11 +520,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()
@ -538,12 +539,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
@ -760,8 +761,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 (
@ -1122,7 +1123,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()
@ -1131,6 +1132,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 PlaylistRow
from playlistrow import PlaylistRow
@dataclass

View File

@ -11,11 +11,12 @@ from sqlalchemy import (
)
from sqlalchemy.orm import aliased
from sqlalchemy.orm.session import Session
from sqlalchemy.sql.elements import BinaryExpression
from classes import ApplicationError, PlaylistRowDTO
# App imports
from classes import PlaylistDTO, TrackDTO
from app import helpers
import helpers
from log import log
from models import (
db,
@ -23,6 +24,7 @@ from models import (
Playdates,
PlaylistRows,
Playlists,
Settings,
Tracks,
)
@ -65,7 +67,7 @@ def get_colour(text: str, foreground: bool = False) -> str:
# Track functions
def add_track_to_header(self, playlistrow_id: int, track_id: int) -> None:
def add_track_to_header(playlistrow_id: int, track_id: int) -> None:
"""
Add a track to this (header) row
"""
@ -87,13 +89,28 @@ def create_track(path: str) -> TrackDTO:
metadata = helpers.get_all_track_metadata(path)
with db.Session() as session:
try:
track = Tracks(session=session, **metadata)
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")
return track_by_id(track_id)
new_track = track_by_id(track_id)
if not new_track:
raise ApplicationError("Unable to create new track")
return new_track
def track_by_id(track_id: int) -> TrackDTO | None:
@ -154,21 +171,79 @@ def track_by_id(track_id: int) -> TrackDTO | None:
return dto
def _tracks_like(where: BinaryExpression) -> 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_like(Tracks.artist.ilike(f"%{filter_str}%"))
def tracks_like_title(filter_str: str) -> list[TrackDTO]:
"""
Return tracks where title is like filter
"""
# TODO: add in playdates as per Tracks.search_titles
with db.Session() as session:
stmt = select(Tracks).where(Tracks.title.ilike(f"%{filter_str}%"))
results = (
session.execute(stmt).scalars().unique().all()
) # `scalars()` extracts ORM objects
return [
TrackDTO(**{k: v for k, v in vars(t).items() if not k.startswith("_")})
for t in results
]
return _tracks_like(Tracks.title.ilike(f"%{filter_str}%"))
# Playlist functions
@ -244,10 +319,14 @@ def create_playlist(name: str, template_id: int) -> PlaylistDTO:
except Exception:
raise ApplicationError("Can't create Playlist")
return playlist_by_id(playlist_id)
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(playlist_row_id: int) -> PlaylistRowDTO | None:
def get_playlist_row(playlistrow_id: int) -> PlaylistRowDTO | None:
"""
Return specific row DTO
"""
@ -286,7 +365,7 @@ def get_playlist_row(playlist_row_id: int) -> PlaylistRowDTO | None:
)
.outerjoin(Tracks, PlaylistRows.track_id == Tracks.id)
.outerjoin(latest_playdate_subq, Tracks.id == latest_playdate_subq.c.track_id)
.where(PlaylistRows.id == playlist_row_id)
.where(PlaylistRows.id == playlistrow_id)
.order_by(PlaylistRows.row_number)
)
@ -428,7 +507,9 @@ def get_playlist_rows(playlist_id: int) -> list[PlaylistRowDTO]:
return dto_list
def insert_row(playlist_id: int, row_number: int, track_id: int, note: str) -> PlaylistRowDTO:
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
"""
@ -455,7 +536,11 @@ def insert_row(playlist_id: int, row_number: int, track_id: int, note: str) -> P
# Sanity check
_check_row_number_sequence(session=session, playlist_id=playlist_id, fix=False)
return get_playlist_row(playlist_row_id=playlist_row_id)
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 playlist_by_id(playlist_id: int) -> PlaylistDTO | None:
@ -485,3 +570,34 @@ def playlist_by_id(playlist_id: int) -> PlaylistDTO | None:
)
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,22 +0,0 @@
# Standard library imports
# PyQt imports
# Third party imports
import vlc # type: ignore
# App imports
from classes import singleton
@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

View File

@ -46,19 +46,19 @@ class MyTestCase(unittest.TestCase):
self.track2 = repository.create_track(track2_path)
# Add tracks and header to playlist
repository.insert_row(
self.row0 = repository.insert_row(
self.playlist.playlist_id,
row_number=0,
track_id=self.track1.track_id,
note="track 1",
)
repository.insert_row(
self.row1 = repository.insert_row(
self.playlist.playlist_id,
row_number=1,
track_id=0,
note="Header row",
)
repository.insert_row(
self.row2 = repository.insert_row(
self.playlist.playlist_id,
row_number=2,
track_id=self.track2.track_id,
@ -70,7 +70,9 @@ class MyTestCase(unittest.TestCase):
db.drop_all()
def test_xxx(self):
"""Comment"""
def test_add_track_to_header(self):
"""Add a track to a header row"""
pass
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