Rewrite of track handling

Combine the old track_manager and playlist data structures into
RowAndTrack data structure.
This commit is contained in:
Keith Edmunds 2024-07-29 18:52:02 +01:00
parent 4a85d7ea84
commit d6f55c5987
8 changed files with 927 additions and 999 deletions

View File

@ -1,15 +1,73 @@
# Standard library imports # Standard library imports
from __future__ import annotations
import ctypes
from dataclasses import dataclass, field from dataclasses import dataclass, field
import datetime as dt
from enum import auto, Enum from enum import auto, Enum
import platform
import threading
from time import sleep
from typing import Any, Optional, NamedTuple from typing import Any, Optional, NamedTuple
# PyQt imports
from PyQt6.QtCore import pyqtSignal, QObject
# Third party imports # Third party imports
import numpy as np
import pyqtgraph as pg # type: ignore
import vlc # type: ignore
# PyQt imports
from PyQt6.QtCore import (
pyqtSignal,
QObject,
QRunnable,
QThread,
QThreadPool,
)
from pyqtgraph import PlotWidget
from pyqtgraph.graphicsItems.PlotDataItem import PlotDataItem # type: ignore
from pyqtgraph.graphicsItems.LinearRegionItem import LinearRegionItem # type: ignore
# App imports # App imports
import helpers from config import Config
from log import log
from models import PlaylistRows
from helpers import (
file_is_unreadable,
get_audio_segment,
show_warning,
singleton,
)
lock = threading.Lock()
# Define the VLC callback function type
VLC_LOG_CB = ctypes.CFUNCTYPE(
None,
ctypes.c_void_p,
ctypes.c_int,
ctypes.c_void_p,
ctypes.c_char_p,
ctypes.c_void_p,
)
# Determine the correct C library for vsnprintf based on the platform
if platform.system() == "Windows":
libc = ctypes.CDLL("msvcrt")
elif platform.system() == "Linux":
libc = ctypes.CDLL("libc.so.6")
elif platform.system() == "Darwin": # macOS
libc = ctypes.CDLL("libc.dylib")
else:
raise OSError("Unsupported operating system")
# Define the vsnprintf function
libc.vsnprintf.argtypes = [
ctypes.c_char_p,
ctypes.c_size_t,
ctypes.c_char_p,
ctypes.c_void_p,
]
libc.vsnprintf.restype = ctypes.c_int
class Col(Enum): class Col(Enum):
@ -25,7 +83,381 @@ class Col(Enum):
NOTE = auto() NOTE = auto()
@helpers.singleton 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 = 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
self.audio_segment = audio[self.start_ms : self.end_ms]
self.graph_array = np.array(self.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
log.debug("issue223: _FadeCurve: create region")
self.region = pg.LinearRegionItem([0, 0], bounds=[0, len(self.graph_array)])
self.GraphWidget.addItem(self.region)
# Update region position
if self.region:
log.debug("issue223: _FadeCurve: update region")
self.region.setRegion([0, ms_of_graph * self.ms_to_array_factor])
class _FadeTrack(QRunnable):
def __init__(self, player: vlc.MediaPlayer, fade_seconds: int) -> None:
super().__init__()
self.player = player
self.fade_seconds = fade_seconds
def run(self) -> None:
"""
Implementation of fading the player
"""
if not self.player:
return
# Reduce volume logarithmically
total_steps = self.fade_seconds * Config.FADEOUT_STEPS_PER_SECOND
db_reduction_per_step = Config.FADEOUT_DB / total_steps
reduction_factor_per_step = pow(10, (db_reduction_per_step / 20))
volume = self.player.audio_get_volume()
for i in range(1, total_steps + 1):
self.player.audio_set_volume(
int(volume * pow(reduction_factor_per_step, i))
)
sleep(1 / Config.FADEOUT_STEPS_PER_SECOND)
self.player.stop()
class _Music:
"""
Manage the playing of music tracks
"""
def __init__(self, name: str) -> None:
self.VLC = vlc.Instance()
self.VLC.set_user_agent(name, name)
self.player: Optional[vlc.MediaPlayer] = None
self.name = name
self.max_volume: int = Config.VLC_VOLUME_DEFAULT
self.start_dt: Optional[dt.datetime] = None
self.player_count: int = 0
# Set up logging
self._set_vlc_log()
@VLC_LOG_CB
def log_callback(data, level, ctx, fmt, args):
try:
# Create a ctypes string buffer to hold the formatted message
buf = ctypes.create_string_buffer(1024)
# Use vsnprintf to format the string with the va_list
libc.vsnprintf(buf, len(buf), fmt, args)
# Decode the formatted message
message = buf.value.decode("utf-8", errors="replace")
log.debug("VLC: " + message)
except Exception as e:
log.error(f"Error in VLC log callback: {e}")
def _set_vlc_log(self):
try:
vlc.libvlc_log_set(self.VLC, self.log_callback, None)
log.info("VLC logging set up successfully")
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 stop(self) -> None:
"""Immediately stop playing"""
log.debug(f"Music[{self.name}].stop()")
self.start_dt = None
if not self.player:
return
p = self.player
self.player = None
self.start_dt = None
with lock:
p.stop()
p.release()
self.player_count -= 1
log.debug(f"_Music.stop: Releasing player {p=}, {self.player_count=}")
p = None
def fade(self, fade_seconds: int) -> None:
"""
Fade the currently playing track.
The actual management of fading runs in its own thread so as not
to hold up the UI during the fade.
"""
if not self.player:
return
if not self.player.get_position() > 0 and self.player.is_playing():
return
if fade_seconds <= 0:
self.stop()
return
# Take a copy of current player to allow another track to be
# started without interfering here
with lock:
p = self.player
self.player = None
pool = QThreadPool.globalInstance()
if pool:
fader = _FadeTrack(p, fade_seconds=fade_seconds)
pool.start(fader)
self.start_dt = None
else:
log.error("_Music: failed to allocate QThreadPool")
def get_playtime(self) -> int:
"""
Return number of milliseconds current track has been playing or
zero if not playing. The vlc function get_time() only updates 3-4
times a second; this function has much better resolution.
"""
if self.start_dt is None:
return 0
now = dt.datetime.now()
elapsed_seconds = (now - self.start_dt).total_seconds()
return int(elapsed_seconds * 1000)
def get_position(self) -> Optional[float]:
"""Return current position"""
if not self.player:
return None
return self.player.get_position()
def is_playing(self) -> bool:
"""
Return True if we're playing
"""
if not self.player:
return False
# There is a discrete time between starting playing a track and
# player.is_playing() returning True, so assume playing if less
# than Config.PLAY_SETTLE microseconds have passed since
# starting play.
return self.start_dt is not None and (
self.player.is_playing()
or (dt.datetime.now() - self.start_dt)
< dt.timedelta(microseconds=Config.PLAY_SETTLE)
)
def play(
self,
path: str,
start_time: dt.datetime,
position: Optional[float] = None,
) -> None:
"""
Start playing the track at path.
Log and return if path not found.
start_time ensures our version and our caller's version of
the start time is the same
"""
log.debug(f"Music[{self.name}].play({path=}, {position=}")
if file_is_unreadable(path):
log.error(f"play({path}): path not readable")
return None
media = self.VLC.media_new_path(path)
if media is None:
log.error(f"_Music:play: failed to create media ({path=})")
show_warning(None, "Error loading file", f"Cannot play file ({path})")
return
self.player = media.player_new_from_media()
if self.player:
_ = self.player.play()
self.set_volume(self.max_volume)
self.player_count += 1
log.debug(f"_Music.play: {self.player_count=}")
if position:
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.
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)
else:
log.error("_Music:play: failed to create media player")
show_warning(None, "Media player", "Unable to create media player")
def set_position(self, position: float) -> None:
"""
Set player position
"""
if self.player:
self.player.set_position(position)
def set_volume(
self, volume: Optional[int] = None, set_default: bool = True
) -> None:
"""Set maximum volume used for player"""
if not self.player:
return
if set_default and volume:
self.max_volume = volume
if volume is None:
volume = Config.VLC_VOLUME_DEFAULT
self.player.audio_set_volume(volume)
# Ensure volume correct
# 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).
for _ in range(3):
current_volume = self.player.audio_get_volume()
if current_volume < volume:
self.player.audio_set_volume(volume)
log.debug(f"Reset from {volume=}")
sleep(0.1)
@singleton
@dataclass @dataclass
class MusicMusterSignals(QObject): class MusicMusterSignals(QObject):
""" """
@ -53,6 +485,294 @@ class MusicMusterSignals(QObject):
super().__init__() super().__init__()
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
# 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 = None
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.signals = MusicMusterSignals()
def __repr__(self) -> str:
return (
f"<RowAndTrack(playlist_id={self.playlist_id}, "
f"row_number={self.row_number}, "
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 not self.player.is_playing():
self.start_time = None
if self.fade_graph:
self.fade_graph.clear()
self.signal_end_of_track()
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.player.set_volume(volume=Config.VLC_VOLUME_DROP3db, set_default=False)
else:
self.player.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.player.get_position()
self.player.fade(fade_seconds)
self.signal_end_of_track()
def is_playing(self) -> bool:
"""
Return True if we're currently playing else False
"""
if self.start_time is None:
return False
return self.player.is_playing()
def move_back(self, ms: int = Config.PREVIEW_BACK_MS) -> None:
"""
Rewind player by ms milliseconds
"""
self.player.adjust_by_ms(ms * -1)
def move_forward(self, ms: int = Config.PREVIEW_ADVANCE_MS) -> None:
"""
Rewind player by ms milliseconds
"""
self.player.adjust_by_ms(ms)
def play(self, position: Optional[float] = None) -> None:
"""Play track"""
log.debug(f"issue223: RowAndTrack: play {self.track_id=}")
now = dt.datetime.now()
self.start_time = now
# Initialise player
self.player = _Music(name=Config.VLC_MAIN_PLAYER_NAME)
self.player.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.player.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 signal_end_of_track(self) -> None:
"""
Send end of track signal unless we are a preview player
"""
self.signals.track_ended_signal.emit()
def stop(self, fade_seconds: int = 0) -> None:
"""
Stop this track playing
"""
self.resume_marker = self.player.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.player.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())
@dataclass @dataclass
class TrackFileData: class TrackFileData:
""" """
@ -70,3 +790,11 @@ class TrackFileData:
class TrackInfo(NamedTuple): class TrackInfo(NamedTuple):
track_id: int track_id: int
row_number: int row_number: int
class TrackSequence:
next: Optional[RowAndTrack] = None
current: Optional[RowAndTrack] = None
previous: Optional[RowAndTrack] = None
track_sequence = TrackSequence()

View File

@ -76,7 +76,7 @@ class PlaylistsTable(Model):
"PlaylistRowsTable", "PlaylistRowsTable",
back_populates="playlist", back_populates="playlist",
cascade="all, delete-orphan", cascade="all, delete-orphan",
order_by="PlaylistRowsTable.plr_rownum", order_by="PlaylistRowsTable.row_number",
) )
def __repr__(self) -> str: def __repr__(self) -> str:
@ -90,7 +90,7 @@ class PlaylistRowsTable(Model):
__tablename__ = "playlist_rows" __tablename__ = "playlist_rows"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
plr_rownum: Mapped[int] row_number: Mapped[int]
note: Mapped[str] = mapped_column( note: Mapped[str] = mapped_column(
String(2048), index=False, default="", nullable=False String(2048), index=False, default="", nullable=False
) )
@ -109,7 +109,7 @@ class PlaylistRowsTable(Model):
return ( return (
f"<PlaylistRows(id={self.id}, playlist_id={self.playlist_id}, " f"<PlaylistRows(id={self.id}, playlist_id={self.playlist_id}, "
f"track_id={self.track_id}, " f"track_id={self.track_id}, "
f"note={self.note}, plr_rownum={self.plr_rownum}>" f"note={self.note}, row_number={self.row_number}>"
) )
@ -135,7 +135,6 @@ class TracksTable(Model):
__tablename__ = "tracks" __tablename__ = "tracks"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
title: Mapped[str] = mapped_column(String(256), index=True)
artist: Mapped[str] = mapped_column(String(256), index=True) artist: Mapped[str] = mapped_column(String(256), index=True)
bitrate: Mapped[Optional[int]] = mapped_column(default=None) bitrate: Mapped[Optional[int]] = mapped_column(default=None)
duration: Mapped[int] = mapped_column(index=True) duration: Mapped[int] = mapped_column(index=True)
@ -145,6 +144,8 @@ class TracksTable(Model):
path: Mapped[str] = mapped_column(String(2048), index=False, unique=True) path: Mapped[str] = mapped_column(String(2048), index=False, unique=True)
silence_at: Mapped[int] = mapped_column(index=False) silence_at: Mapped[int] = mapped_column(index=False)
start_gap: Mapped[int] = mapped_column(index=False) start_gap: Mapped[int] = mapped_column(index=False)
title: Mapped[str] = mapped_column(String(256), index=True)
playlistrows: Mapped[List[PlaylistRowsTable]] = relationship( playlistrows: Mapped[List[PlaylistRowsTable]] = relationship(
"PlaylistRowsTable", back_populates="track" "PlaylistRowsTable", back_populates="track"
) )

View File

@ -312,7 +312,7 @@ class PlaylistRows(dbtables.PlaylistRowsTable):
self.playlist_id = playlist_id self.playlist_id = playlist_id
self.track_id = track_id self.track_id = track_id
self.plr_rownum = row_number self.row_number = row_number
self.note = note self.note = note
session.add(self) session.add(self)
session.commit() session.commit()
@ -338,7 +338,7 @@ class PlaylistRows(dbtables.PlaylistRowsTable):
PlaylistRows( PlaylistRows(
session=session, session=session,
playlist_id=dst_id, playlist_id=dst_id,
row_number=plr.plr_rownum, row_number=plr.row_number,
note=plr.note, note=plr.note,
track_id=plr.track_id, track_id=plr.track_id,
) )
@ -357,7 +357,7 @@ class PlaylistRows(dbtables.PlaylistRowsTable):
.options(joinedload(cls.track)) .options(joinedload(cls.track))
.where( .where(
PlaylistRows.playlist_id == playlist_id, PlaylistRows.playlist_id == playlist_id,
PlaylistRows.plr_rownum == row_number, PlaylistRows.row_number == row_number,
) )
# .options(joinedload(Tracks.playdates)) # .options(joinedload(Tracks.playdates))
) )
@ -375,7 +375,7 @@ class PlaylistRows(dbtables.PlaylistRowsTable):
select(PlaylistRows) select(PlaylistRows)
.options(joinedload(cls.track)) .options(joinedload(cls.track))
.where(PlaylistRows.playlist_id == playlist_id) .where(PlaylistRows.playlist_id == playlist_id)
.order_by(PlaylistRows.plr_rownum) .order_by(PlaylistRows.row_number)
# .options(joinedload(Tracks.playdates)) # .options(joinedload(Tracks.playdates))
) )
@ -391,7 +391,7 @@ class PlaylistRows(dbtables.PlaylistRowsTable):
session.execute( session.execute(
delete(PlaylistRows).where( delete(PlaylistRows).where(
PlaylistRows.playlist_id == playlist_id, PlaylistRows.playlist_id == playlist_id,
PlaylistRows.plr_rownum > maxrow, PlaylistRows.row_number > maxrow,
) )
) )
session.commit() session.commit()
@ -405,7 +405,7 @@ class PlaylistRows(dbtables.PlaylistRowsTable):
session.execute( session.execute(
delete(PlaylistRows).where( delete(PlaylistRows).where(
PlaylistRows.playlist_id == playlist_id, PlaylistRows.playlist_id == playlist_id,
PlaylistRows.plr_rownum == row_number, PlaylistRows.row_number == row_number,
) )
) )
@ -418,11 +418,11 @@ class PlaylistRows(dbtables.PlaylistRowsTable):
plrs = session.scalars( plrs = session.scalars(
select(PlaylistRows) select(PlaylistRows)
.where(PlaylistRows.playlist_id == playlist_id) .where(PlaylistRows.playlist_id == playlist_id)
.order_by(PlaylistRows.plr_rownum) .order_by(PlaylistRows.row_number)
).all() ).all()
for i, plr in enumerate(plrs): for i, plr in enumerate(plrs):
plr.plr_rownum = i plr.row_number = i
# Ensure new row numbers are available to the caller # Ensure new row numbers are available to the caller
session.commit() session.commit()
@ -439,7 +439,7 @@ class PlaylistRows(dbtables.PlaylistRowsTable):
plrs = session.scalars( plrs = session.scalars(
select(cls) select(cls)
.where(cls.playlist_id == playlist_id, cls.id.in_(plr_ids)) .where(cls.playlist_id == playlist_id, cls.id.in_(plr_ids))
.order_by(cls.plr_rownum) .order_by(cls.row_number)
).all() ).all()
return plrs return plrs
@ -449,7 +449,7 @@ class PlaylistRows(dbtables.PlaylistRowsTable):
"""Return the last used row for playlist, or None if no rows""" """Return the last used row for playlist, or None if no rows"""
return session.execute( return session.execute(
select(func.max(PlaylistRows.plr_rownum)).where( select(func.max(PlaylistRows.row_number)).where(
PlaylistRows.playlist_id == playlist_id PlaylistRows.playlist_id == playlist_id
) )
).scalar_one() ).scalar_one()
@ -481,7 +481,7 @@ class PlaylistRows(dbtables.PlaylistRowsTable):
plrs = session.scalars( plrs = session.scalars(
select(cls) select(cls)
.where(cls.playlist_id == playlist_id, cls.played.is_(True)) .where(cls.playlist_id == playlist_id, cls.played.is_(True))
.order_by(cls.plr_rownum) .order_by(cls.row_number)
).all() ).all()
return plrs return plrs
@ -500,7 +500,7 @@ class PlaylistRows(dbtables.PlaylistRowsTable):
query = select(cls).where( query = select(cls).where(
cls.playlist_id == playlist_id, cls.track_id.is_not(None) cls.playlist_id == playlist_id, cls.track_id.is_not(None)
) )
plrs = session.scalars((query).order_by(cls.plr_rownum)).all() plrs = session.scalars((query).order_by(cls.row_number)).all()
return plrs return plrs
@ -520,7 +520,7 @@ class PlaylistRows(dbtables.PlaylistRowsTable):
cls.track_id.is_not(None), cls.track_id.is_not(None),
cls.played.is_(False), cls.played.is_(False),
) )
.order_by(cls.plr_rownum) .order_by(cls.row_number)
).all() ).all()
return plrs return plrs
@ -558,17 +558,17 @@ class PlaylistRows(dbtables.PlaylistRowsTable):
update(PlaylistRows) update(PlaylistRows)
.where( .where(
(PlaylistRows.playlist_id == playlist_id), (PlaylistRows.playlist_id == playlist_id),
(PlaylistRows.plr_rownum >= starting_row), (PlaylistRows.row_number >= starting_row),
) )
.values(plr_rownum=PlaylistRows.plr_rownum + move_by) .values(row_number=PlaylistRows.row_number + move_by)
) )
@staticmethod @staticmethod
def update_plr_rownumbers( def update_plr_row_numbers(
session: Session, playlist_id: int, sqla_map: List[dict[str, int]] session: Session, playlist_id: int, sqla_map: List[dict[str, int]]
) -> None: ) -> None:
""" """
Take a {plrid: plr_rownum} dictionary and update the row numbers accordingly Take a {plrid: row_number} dictionary and update the row numbers accordingly
""" """
# Update database. Ref: # Update database. Ref:
@ -577,9 +577,9 @@ class PlaylistRows(dbtables.PlaylistRowsTable):
update(PlaylistRows) update(PlaylistRows)
.where( .where(
PlaylistRows.playlist_id == playlist_id, PlaylistRows.playlist_id == playlist_id,
PlaylistRows.id == bindparam("plrid"), PlaylistRows.id == bindparam("playlistrow_id"),
) )
.values(plr_rownum=bindparam("plr_rownum")) .values(row_number=bindparam("row_number"))
) )
session.connection().execute(stmt, sqla_map) session.connection().execute(stmt, sqla_map)

View File

@ -53,8 +53,10 @@ import stackprinter # type: ignore
# App imports # App imports
from classes import ( from classes import (
MusicMusterSignals, MusicMusterSignals,
RowAndTrack,
TrackFileData, TrackFileData,
TrackInfo, TrackInfo,
track_sequence,
) )
from config import Config from config import Config
from dialogs import TrackSelectDialog, ReplaceFilesDialog from dialogs import TrackSelectDialog, ReplaceFilesDialog
@ -63,10 +65,6 @@ from log import log
from models import db, Playdates, PlaylistRows, Playlists, Settings, Tracks from models import db, Playdates, PlaylistRows, Playlists, Settings, Tracks
from playlistmodel import PlaylistModel, PlaylistProxyModel from playlistmodel import PlaylistModel, PlaylistProxyModel
from playlists import PlaylistTab from playlists import PlaylistTab
from trackmanager import (
MainTrackManager,
track_sequence,
)
from ui import icons_rc # noqa F401 from ui import icons_rc # noqa F401
from ui.dlg_SelectPlaylist_ui import Ui_dlgSelectPlaylist # type: ignore from ui.dlg_SelectPlaylist_ui import Ui_dlgSelectPlaylist # type: ignore
from ui.downloadcsv_ui import Ui_DateSelect # type: ignore from ui.downloadcsv_ui import Ui_DateSelect # type: ignore
@ -1108,7 +1106,7 @@ class Window(QMainWindow, Ui_MainWindow):
- Clear next track - Clear next track
- Restore volume if -3dB active - Restore volume if -3dB active
- Play (new) current track. - Play (new) current track.
- Show closing volume graph - Show fade graph
- Notify model - Notify model
- Note that track is now playing - Note that track is now playing
- Disable play next controls - Disable play next controls
@ -1129,8 +1127,8 @@ class Window(QMainWindow, Ui_MainWindow):
# Issue #223 concerns a very short pause (maybe 0.1s) sometimes # Issue #223 concerns a very short pause (maybe 0.1s) sometimes
# when starting to play at track. # when starting to play at track.
# Resolution appears to be to disable timer10 for the first ten # Resolution appears to be to disable timer10 for a short time.
# seconds of playback. Re-enable in update_clocks. # Length of time and re-enabling of timer10 both in update_clocks.
self.timer10.stop() self.timer10.stop()
log.debug("issue223: play_next: 10ms timer disabled") log.debug("issue223: play_next: 10ms timer disabled")
@ -1421,6 +1419,7 @@ class Window(QMainWindow, Ui_MainWindow):
# We want to use play_next() to resume, so copy the previous # We want to use play_next() to resume, so copy the previous
# track to the next track: # track to the next track:
track_sequence.next = track_sequence.previous track_sequence.next = track_sequence.previous
track_sequence.next.create_fade_graph()
# Now resume playing the now-next track # Now resume playing the now-next track
self.play_next(track_sequence.next.resume_marker) self.play_next(track_sequence.next.resume_marker)
@ -1569,7 +1568,7 @@ class Window(QMainWindow, Ui_MainWindow):
self.statusbar.showMessage(message, timing) self.statusbar.showMessage(message, timing)
def show_track(self, playlist_track: MainTrackManager) -> None: def show_track(self, playlist_track: RowAndTrack) -> None:
"""Scroll to show track in plt""" """Scroll to show track in plt"""
# Switch to the correct tab # Switch to the correct tab
@ -1678,12 +1677,6 @@ class Window(QMainWindow, Ui_MainWindow):
self.preview_manager.get_playtime() / 1000, 60 self.preview_manager.get_playtime() / 1000, 60
) )
self.label_intro_timer.setText(f"{int(minutes)}:{seconds:04.1f}") self.label_intro_timer.setText(f"{int(minutes)}:{seconds:04.1f}")
# if self.preview_track_manager.time_remaining_intro() <= 50:
# self.label_intro_timer.setStyleSheet(
# f"background: {Config.COLOUR_WARNING_TIMER}"
# )
# else:
# self.label_intro_timer.setStyleSheet("")
else: else:
self.btnPreview.setChecked(False) self.btnPreview.setChecked(False)
self.label_intro_timer.setText("0.0") self.label_intro_timer.setText("0.0")
@ -1717,7 +1710,7 @@ class Window(QMainWindow, Ui_MainWindow):
# see play_next() and issue #223. # see play_next() and issue #223.
# TODO: find a better way of handling this # TODO: find a better way of handling this
if ( if (
track_sequence.current.time_playing() > 10000 track_sequence.current.time_playing() > 5000
and not self.timer10.isActive() and not self.timer10.isActive()
): ):
self.timer10.start(10) self.timer10.start(10)

View File

@ -31,7 +31,12 @@ import obswebsocket # type: ignore
# import snoop # type: ignore # import snoop # type: ignore
# App imports # App imports
from classes import Col, MusicMusterSignals from classes import (
Col,
MusicMusterSignals,
RowAndTrack,
track_sequence,
)
from config import Config from config import Config
from helpers import ( from helpers import (
file_is_unreadable, file_is_unreadable,
@ -42,92 +47,12 @@ from helpers import (
) )
from log import log from log import log
from models import db, NoteColours, Playdates, PlaylistRows, Tracks from models import db, NoteColours, Playdates, PlaylistRows, Tracks
from trackmanager import (
MainTrackManager,
track_sequence,
)
HEADER_NOTES_COLUMN = 1 HEADER_NOTES_COLUMN = 1
scene_change_re = re.compile(r"SetScene=\[([^[\]]*)\]") scene_change_re = re.compile(r"SetScene=\[([^[\]]*)\]")
class _PlaylistRowData:
def __init__(self, plr: PlaylistRows) -> None:
"""
Populate PlaylistRowData from database PlaylistRows record
"""
self.artist: str = ""
self.bitrate = 0
self.duration: int = 0
self.intro: Optional[int] = None
self.lastplayed: dt.datetime = Config.EPOCH
self.path = ""
self.played = False
self.start_gap: Optional[int] = None
self.title: str = ""
self.start_time: Optional[dt.datetime] = None
self.end_time: Optional[dt.datetime] = None
self.plrid: int = plr.id
self.plr_rownum: int = plr.plr_rownum
self.note: str = plr.note
self.track_id = plr.track_id
if plr.track:
self.start_gap = plr.track.start_gap
self.title = plr.track.title
self.artist = plr.track.artist
self.duration = plr.track.duration
self.intro = plr.track.intro
self.played = plr.played
if plr.track.playdates:
self.lastplayed = max([a.lastplayed for a in plr.track.playdates])
else:
self.lastplayed = Config.EPOCH
self.bitrate = plr.track.bitrate or 0
self.path = plr.track.path
def __repr__(self) -> str:
return (
f"<PlaylistRowData: plrid={self.plrid}, plr_rownum={self.plr_rownum}, "
f"note='{self.note}', title='{self.title}', artist='{self.artist}'>"
)
def set_start(
self, modified_rows: list[int], start: Optional[dt.datetime]
) -> Optional[dt.datetime]:
"""
Set start time for this row
Update passed modified rows list if we changed the row.
Return new start time
"""
changed = False
if self.start_time != start:
self.start_time = start
changed = True
if start is None:
if self.end_time is not None:
self.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.end_time != end_time:
self.end_time = end_time
changed = True
if changed and self.plr_rownum not in modified_rows:
modified_rows.append(self.plr_rownum)
return new_start_time
class PlaylistModel(QAbstractTableModel): class PlaylistModel(QAbstractTableModel):
""" """
The Playlist Model The Playlist Model
@ -155,7 +80,7 @@ class PlaylistModel(QAbstractTableModel):
self.playlist_id = playlist_id self.playlist_id = playlist_id
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.playlist_rows: dict[int, _PlaylistRowData] = {} self.playlist_rows: dict[int, RowAndTrack] = {}
self.signals = MusicMusterSignals() self.signals = MusicMusterSignals()
self.played_tracks_hidden = False self.played_tracks_hidden = False
@ -167,7 +92,6 @@ class PlaylistModel(QAbstractTableModel):
PlaylistRows.fixup_rownumbers(session, playlist_id) PlaylistRows.fixup_rownumbers(session, playlist_id)
# Populate self.playlist_rows # Populate self.playlist_rows
self.refresh_data(session) self.refresh_data(session)
session.commit()
self.update_track_times() self.update_track_times()
def __repr__(self) -> str: def __repr__(self) -> str:
@ -186,27 +110,27 @@ class PlaylistModel(QAbstractTableModel):
# Get existing row # Get existing row
try: try:
prd = self.playlist_rows[row_number] rat = self.playlist_rows[row_number]
except KeyError: except KeyError:
log.error( log.error(
f"KeyError in PlaylistModel:add_track_to_header " f"KeyError in PlaylistModel:add_track_to_header "
f"{row_number=}, {track_id=}, {len(self.playlist_rows)=}" f"{row_number=}, {track_id=}, {len(self.playlist_rows)=}"
) )
return return
if prd.path: if rat.path:
log.error( log.error(
f"Error in PlaylistModel:add_track_to_header ({prd=}, " f"Error in PlaylistModel:add_track_to_header ({rat=}, "
"Header row already has track associated" "Header row already has track associated"
) )
return return
with db.Session() as session: with db.Session() as session:
plr = session.get(PlaylistRows, prd.plrid) playlistrow = session.get(PlaylistRows, rat.playlistrow_id)
if plr: if playlistrow:
# Add track to PlaylistRows # Add track to PlaylistRows
plr.track_id = track_id playlistrow.track_id = track_id
# Add any further note (header will already have a note) # Add any further note (header will already have a note)
if note: if note:
plr.note += "\n" + note playlistrow.note += "\n" + note
# Update local copy # Update local copy
self.refresh_row(session, row_number) self.refresh_row(session, row_number)
# Repaint row # Repaint row
@ -215,7 +139,7 @@ class PlaylistModel(QAbstractTableModel):
self.signals.resize_rows_signal.emit(self.playlist_id) self.signals.resize_rows_signal.emit(self.playlist_id)
def background_role(self, row: int, column: int, prd: _PlaylistRowData) -> QBrush: def background_role(self, row: int, column: int, rat: RowAndTrack) -> QBrush:
"""Return background setting""" """Return background setting"""
# Handle entire row colouring # Handle entire row colouring
@ -223,36 +147,36 @@ class PlaylistModel(QAbstractTableModel):
if self.is_header_row(row): if self.is_header_row(row):
# Check for specific header colouring # Check for specific header colouring
with db.Session() as session: with db.Session() as session:
note_colour = NoteColours.get_colour(session, prd.note) note_colour = NoteColours.get_colour(session, rat.note)
if note_colour: if note_colour:
return QBrush(QColor(note_colour)) return QBrush(QColor(note_colour))
else: else:
return QBrush(QColor(Config.COLOUR_NOTES_PLAYLIST)) return QBrush(QColor(Config.COLOUR_NOTES_PLAYLIST))
# Unreadable track file # Unreadable track file
if file_is_unreadable(prd.path): if file_is_unreadable(rat.path):
return QBrush(QColor(Config.COLOUR_UNREADABLE)) return QBrush(QColor(Config.COLOUR_UNREADABLE))
# Current track # Current track
if track_sequence.current and track_sequence.current.track_id == prd.track_id: if track_sequence.current and track_sequence.current.track_id == rat.track_id:
return QBrush(QColor(Config.COLOUR_CURRENT_PLAYLIST)) return QBrush(QColor(Config.COLOUR_CURRENT_PLAYLIST))
# Next track # Next track
if track_sequence.next and track_sequence.next.track_id == prd.track_id: if track_sequence.next and track_sequence.next.track_id == rat.track_id:
return QBrush(QColor(Config.COLOUR_NEXT_PLAYLIST)) return QBrush(QColor(Config.COLOUR_NEXT_PLAYLIST))
# Individual cell colouring # Individual cell colouring
if column == Col.START_GAP.value: if column == Col.START_GAP.value:
if prd.start_gap and prd.start_gap >= Config.START_GAP_WARNING_THRESHOLD: if rat.start_gap and rat.start_gap >= Config.START_GAP_WARNING_THRESHOLD:
return QBrush(QColor(Config.COLOUR_LONG_START)) return QBrush(QColor(Config.COLOUR_LONG_START))
if column == Col.BITRATE.value: if column == Col.BITRATE.value:
if prd.bitrate < Config.BITRATE_LOW_THRESHOLD: if not rat.bitrate or rat.bitrate < Config.BITRATE_LOW_THRESHOLD:
return QBrush(QColor(Config.COLOUR_BITRATE_LOW)) return QBrush(QColor(Config.COLOUR_BITRATE_LOW))
elif prd.bitrate and prd.bitrate < Config.BITRATE_OK_THRESHOLD: elif rat.bitrate < Config.BITRATE_OK_THRESHOLD:
return QBrush(QColor(Config.COLOUR_BITRATE_MEDIUM)) return QBrush(QColor(Config.COLOUR_BITRATE_MEDIUM))
else: else:
return QBrush(QColor(Config.COLOUR_BITRATE_OK)) return QBrush(QColor(Config.COLOUR_BITRATE_OK))
if column == Col.NOTE.value: if column == Col.NOTE.value:
if prd.note: if rat.note:
with db.Session() as session: with db.Session() as session:
note_colour = NoteColours.get_colour(session, prd.note) note_colour = NoteColours.get_colour(session, rat.note)
if note_colour: if note_colour:
return QBrush(QColor(note_colour)) return QBrush(QColor(note_colour))
@ -296,6 +220,10 @@ class PlaylistModel(QAbstractTableModel):
log.debug("Call OBS scene change") log.debug("Call OBS scene change")
self.obs_scene_change(row_number) self.obs_scene_change(row_number)
if not track_sequence.current.track_id:
log.error(f"current_track_started() called with {track_sequence.current.track_id=}")
return
with db.Session() as session: with db.Session() as session:
# Update Playdates in database # Update Playdates in database
log.debug("update playdates") log.debug("update playdates")
@ -303,12 +231,12 @@ class PlaylistModel(QAbstractTableModel):
# Mark track as played in playlist # Mark track as played in playlist
log.debug("Mark track as played") log.debug("Mark track as played")
plr = session.get(PlaylistRows, track_sequence.current.plr_id) plr = session.get(PlaylistRows, track_sequence.current.playlistrow_id)
if plr: if plr:
plr.played = True plr.played = True
self.refresh_row(session, plr.plr_rownum) self.refresh_row(session, plr.row_number)
else: else:
log.error(f"Can't retrieve plr, {track_sequence.current.plr_id=}") log.error(f"Can't retrieve plr, {track_sequence.current.playlistrow_id=}")
# Update colour and times for current row # Update colour and times for current row
self.invalidate_row(row_number) self.invalidate_row(row_number)
@ -353,8 +281,8 @@ class PlaylistModel(QAbstractTableModel):
row = index.row() row = index.row()
column = index.column() column = index.column()
# prd for playlist row data as it's used a lot # rat for playlist row data as it's used a lot
prd = self.playlist_rows[row] rat = self.playlist_rows[row]
# Dispatch to role-specific functions # Dispatch to role-specific functions
dispatch_table = { dispatch_table = {
@ -366,7 +294,7 @@ class PlaylistModel(QAbstractTableModel):
} }
if role in dispatch_table: if role in dispatch_table:
return QVariant(dispatch_table[role](row, column, prd)) return QVariant(dispatch_table[role](row, column, rat))
# Document other roles but don't use them # Document other roles but don't use them
if role in [ if role in [
@ -412,7 +340,7 @@ class PlaylistModel(QAbstractTableModel):
self.reset_track_sequence_row_numbers() self.reset_track_sequence_row_numbers()
def display_role(self, row: int, column: int, prd: _PlaylistRowData) -> QVariant: def display_role(self, row: int, column: int, rat: RowAndTrack) -> QVariant:
""" """
Return text for display Return text for display
""" """
@ -428,40 +356,40 @@ class PlaylistModel(QAbstractTableModel):
if self.is_header_row(row): if self.is_header_row(row):
if column == HEADER_NOTES_COLUMN: if column == HEADER_NOTES_COLUMN:
header_text = self.header_text(prd) header_text = self.header_text(rat)
if not header_text: if not header_text:
return QVariant(Config.TEXT_NO_TRACK_NO_NOTE) return QVariant(Config.TEXT_NO_TRACK_NO_NOTE)
else: else:
return QVariant(self.header_text(prd)) return QVariant(self.header_text(rat))
else: else:
return QVariant() return QVariant()
if column == Col.START_TIME.value: if column == Col.START_TIME.value:
start_time = prd.start_time start_time = rat.forecast_start_time
if start_time: if start_time:
return QVariant(start_time.strftime(Config.TRACK_TIME_FORMAT)) return QVariant(start_time.strftime(Config.TRACK_TIME_FORMAT))
return QVariant() return QVariant()
if column == Col.END_TIME.value: if column == Col.END_TIME.value:
end_time = prd.end_time end_time = rat.forecast_end_time
if end_time: if end_time:
return QVariant(end_time.strftime(Config.TRACK_TIME_FORMAT)) return QVariant(end_time.strftime(Config.TRACK_TIME_FORMAT))
return QVariant() return QVariant()
if column == Col.INTRO.value: if column == Col.INTRO.value:
if prd.intro: if rat.intro:
return QVariant(f"{prd.intro / 1000:{Config.INTRO_SECONDS_FORMAT}}") return QVariant(f"{rat.intro / 1000:{Config.INTRO_SECONDS_FORMAT}}")
else: else:
return QVariant() return QVariant()
dispatch_table = { dispatch_table = {
Col.ARTIST.value: QVariant(prd.artist), Col.ARTIST.value: QVariant(rat.artist),
Col.BITRATE.value: QVariant(prd.bitrate), Col.BITRATE.value: QVariant(rat.bitrate),
Col.DURATION.value: QVariant(ms_to_mmss(prd.duration)), Col.DURATION.value: QVariant(ms_to_mmss(rat.duration)),
Col.LAST_PLAYED.value: QVariant(get_relative_date(prd.lastplayed)), Col.LAST_PLAYED.value: QVariant(get_relative_date(rat.lastplayed)),
Col.NOTE.value: QVariant(prd.note), Col.NOTE.value: QVariant(rat.note),
Col.START_GAP.value: QVariant(prd.start_gap), Col.START_GAP.value: QVariant(rat.start_gap),
Col.TITLE.value: QVariant(prd.title), Col.TITLE.value: QVariant(rat.title),
} }
if column in dispatch_table: if column in dispatch_table:
return dispatch_table[column] return dispatch_table[column]
@ -483,7 +411,7 @@ class PlaylistModel(QAbstractTableModel):
super().endResetModel() super().endResetModel()
self.reset_track_sequence_row_numbers() self.reset_track_sequence_row_numbers()
def edit_role(self, row: int, column: int, prd: _PlaylistRowData) -> QVariant: def edit_role(self, row: int, column: int, rat: RowAndTrack) -> QVariant:
""" """
Return text for editing Return text for editing
""" """
@ -491,16 +419,16 @@ class PlaylistModel(QAbstractTableModel):
# If this is a header row and we're being asked for the # If this is a header row and we're being asked for the
# HEADER_NOTES_COLUMN, return the note value # HEADER_NOTES_COLUMN, return the note value
if self.is_header_row(row) and column == HEADER_NOTES_COLUMN: if self.is_header_row(row) and column == HEADER_NOTES_COLUMN:
return QVariant(prd.note) return QVariant(rat.note)
if column == Col.INTRO.value: if column == Col.INTRO.value:
return QVariant(prd.intro) return QVariant(rat.intro)
if column == Col.TITLE.value: if column == Col.TITLE.value:
return QVariant(prd.title) return QVariant(rat.title)
if column == Col.ARTIST.value: if column == Col.ARTIST.value:
return QVariant(prd.artist) return QVariant(rat.artist)
if column == Col.NOTE.value: if column == Col.NOTE.value:
return QVariant(prd.note) return QVariant(rat.note)
return QVariant() return QVariant()
@ -527,7 +455,7 @@ class PlaylistModel(QAbstractTableModel):
return default return default
def font_role(self, row: int, column: int, prd: _PlaylistRowData) -> QVariant: def font_role(self, row: int, column: int, rat: RowAndTrack) -> QVariant:
""" """
Return font Return font
""" """
@ -586,7 +514,7 @@ class PlaylistModel(QAbstractTableModel):
log.debug(f"get_new_row_number() return: {new_row_number=}") log.debug(f"get_new_row_number() return: {new_row_number=}")
return new_row_number return new_row_number
def get_row_info(self, row_number: int) -> _PlaylistRowData: def get_row_info(self, row_number: int) -> RowAndTrack:
""" """
Return info about passed row Return info about passed row
""" """
@ -624,7 +552,7 @@ class PlaylistModel(QAbstractTableModel):
""" """
result = [ result = [
a.plr_rownum a.row_number
for a in self.playlist_rows.values() for a in self.playlist_rows.values()
if not a.played and a.track_id is not None if not a.played and a.track_id is not None
] ]
@ -670,31 +598,31 @@ class PlaylistModel(QAbstractTableModel):
return QVariant() return QVariant()
def header_text(self, prd: _PlaylistRowData) -> str: def header_text(self, rat: RowAndTrack) -> str:
""" """
Process possible section timing directives embeded in header Process possible section timing directives embeded in header
""" """
if prd.note.endswith("+"): if rat.note.endswith("+"):
return self.start_of_timed_section_header(prd) return self.start_of_timed_section_header(rat)
elif prd.note.endswith("="): elif rat.note.endswith("="):
return self.section_subtotal_header(prd) return self.section_subtotal_header(rat)
elif prd.note == "-": elif rat.note == "-":
# If the hyphen is the only thing on the line, echo the note # If the hyphen is the only thing on the line, echo the note
# that started the section without the trailing "+". # that started the section without the trailing "+".
for row_number in range(prd.plr_rownum - 1, -1, -1): for row_number in range(rat.row_number - 1, -1, -1):
row_prd = self.playlist_rows[row_number] row_rat = self.playlist_rows[row_number]
if self.is_header_row(row_number): if self.is_header_row(row_number):
if row_prd.note.endswith("-"): if row_rat.note.endswith("-"):
# We didn't find a matching section start # We didn't find a matching section start
break break
if row_prd.note.endswith("+"): if row_rat.note.endswith("+"):
return f"[End: {row_prd.note[:-1]}]" return f"[End: {row_rat.note[:-1]}]"
return "-" return "-"
return prd.note return rat.note
def hide_played_tracks(self, hide: bool) -> None: def hide_played_tracks(self, hide: bool) -> None:
""" """
@ -772,7 +700,7 @@ class PlaylistModel(QAbstractTableModel):
return self.playlist_rows[row_number].played return self.playlist_rows[row_number].played
def is_track_in_playlist(self, track_id: int) -> Optional[_PlaylistRowData]: def is_track_in_playlist(self, track_id: int) -> Optional[RowAndTrack]:
""" """
If this track_id is in the playlist, return the PlaylistRowData object If this track_id is in the playlist, return the PlaylistRowData object
else return None else return None
@ -791,7 +719,7 @@ class PlaylistModel(QAbstractTableModel):
with db.Session() as session: with db.Session() as session:
for row_number in row_numbers: for row_number in row_numbers:
plr = session.get(PlaylistRows, self.playlist_rows[row_number].plrid) plr = session.get(PlaylistRows, self.playlist_rows[row_number].row_number)
if not plr: if not plr:
return return
plr.played = False plr.played = False
@ -860,15 +788,15 @@ class PlaylistModel(QAbstractTableModel):
if track_sequence.next and track_sequence.next.row_number in row_map: if track_sequence.next and track_sequence.next.row_number in row_map:
track_sequence.next.row_number = row_map[track_sequence.next.row_number] track_sequence.next.row_number = row_map[track_sequence.next.row_number]
# For SQLAlchemy, build a list of dictionaries that map plrid to # For SQLAlchemy, build a list of dictionaries that map playlistrow_id to
# new row number: # new row number:
sqla_map: list[dict[str, int]] = [] sqla_map: list[dict[str, int]] = []
for oldrow, newrow in row_map.items(): for oldrow, newrow in row_map.items():
plrid = self.playlist_rows[oldrow].plrid playlistrow_id = self.playlist_rows[oldrow].playlistrow_id
sqla_map.append({"plrid": plrid, "plr_rownum": newrow}) sqla_map.append({"playlistrow_id": playlistrow_id, "row_number": newrow})
with db.Session() as session: with db.Session() as session:
PlaylistRows.update_plr_rownumbers(session, self.playlist_id, sqla_map) PlaylistRows.update_plr_row_numbers(session, self.playlist_id, sqla_map)
session.commit() session.commit()
# Update playlist_rows # Update playlist_rows
self.refresh_data(session) self.refresh_data(session)
@ -914,19 +842,19 @@ class PlaylistModel(QAbstractTableModel):
for row_group in row_groups: for row_group in row_groups:
super().beginRemoveRows(QModelIndex(), min(row_group), max(row_group)) super().beginRemoveRows(QModelIndex(), min(row_group), max(row_group))
for plr in PlaylistRows.plrids_to_plrs( for playlist_row in PlaylistRows.plrids_to_plrs(
session, session,
self.playlist_id, self.playlist_id,
[self.playlist_rows[a].plrid for a in row_group], [self.playlist_rows[a].playlistrow_id for a in row_group],
): ):
if ( if (
track_sequence.current track_sequence.current
and plr.id == track_sequence.current.plr_id and playlist_row.id == track_sequence.current.playlistrow_id
): ):
# Don't move current track # Don't move current track
continue continue
plr.playlist_id = to_playlist_id playlist_row.playlist_id = to_playlist_id
plr.plr_rownum = next_to_row playlist_row.row_number = next_to_row
next_to_row += 1 next_to_row += 1
self.refresh_data(session) self.refresh_data(session)
super().endRemoveRows() super().endRemoveRows()
@ -942,17 +870,17 @@ class PlaylistModel(QAbstractTableModel):
self.update_track_times() self.update_track_times()
def move_track_add_note( def move_track_add_note(
self, new_row_number: int, existing_prd: _PlaylistRowData, note: str self, new_row_number: int, existing_rat: RowAndTrack, note: str
) -> None: ) -> None:
""" """
Move existing_prd track to new_row_number and append note to any existing note Move existing_rat track to new_row_number and append note to any existing note
""" """
log.info(f"move_track_add_note({new_row_number=}, {existing_prd=}, {note=}") log.info(f"move_track_add_note({new_row_number=}, {existing_rat=}, {note=}")
if note: if note:
with db.Session() as session: with db.Session() as session:
plr = session.get(PlaylistRows, existing_prd.plrid) plr = session.get(PlaylistRows, existing_rat.playlistrow_id)
if plr: if plr:
if plr.note: if plr.note:
plr.note += "\n" + note plr.note += "\n" + note
@ -962,26 +890,26 @@ class PlaylistModel(QAbstractTableModel):
# Carry out the move outside of the session context to ensure # Carry out the move outside of the session context to ensure
# database updated with any note change # database updated with any note change
self.move_rows([existing_prd.plr_rownum], new_row_number) self.move_rows([existing_rat.row_number], new_row_number)
self.signals.resize_rows_signal.emit(self.playlist_id) self.signals.resize_rows_signal.emit(self.playlist_id)
def move_track_to_header( def move_track_to_header(
self, self,
header_row_number: int, header_row_number: int,
existing_prd: _PlaylistRowData, existing_rat: RowAndTrack,
note: Optional[str], note: Optional[str],
) -> None: ) -> None:
""" """
Add the existing_prd track details to the existing header at header_row_number Add the existing_rat track details to the existing header at header_row_number
""" """
log.info(f"move_track_to_header({header_row_number=}, {existing_prd=}, {note=}") log.info(f"move_track_to_header({header_row_number=}, {existing_rat=}, {note=}")
if existing_prd.track_id: if existing_rat.track_id:
if note and existing_prd.note: if note and existing_rat.note:
note += "\n" + existing_prd.note note += "\n" + existing_rat.note
self.add_track_to_header(header_row_number, existing_prd.track_id, note) self.add_track_to_header(header_row_number, existing_rat.track_id, note)
self.delete_rows([existing_prd.plr_rownum]) self.delete_rows([existing_rat.row_number])
def obs_scene_change(self, row_number: int) -> None: def obs_scene_change(self, row_number: int) -> None:
""" """
@ -1050,13 +978,13 @@ class PlaylistModel(QAbstractTableModel):
# Populate self.playlist_rows with playlist data # Populate self.playlist_rows with playlist data
self.playlist_rows.clear() self.playlist_rows.clear()
for p in PlaylistRows.deep_rows(session, self.playlist_id): for p in PlaylistRows.deep_rows(session, self.playlist_id):
self.playlist_rows[p.plr_rownum] = _PlaylistRowData(p) self.playlist_rows[p.row_number] = RowAndTrack(p)
def refresh_row(self, session, row_number): def refresh_row(self, session, row_number):
"""Populate dict for one row from database""" """Populate dict for one row from database"""
p = PlaylistRows.deep_row(session, self.playlist_id, row_number) p = PlaylistRows.deep_row(session, self.playlist_id, row_number)
self.playlist_rows[row_number] = _PlaylistRowData(p) self.playlist_rows[row_number] = RowAndTrack(p)
def remove_track(self, row_number: int) -> None: def remove_track(self, row_number: int) -> None:
""" """
@ -1066,7 +994,7 @@ class PlaylistModel(QAbstractTableModel):
log.info(f"remove_track({row_number=})") log.info(f"remove_track({row_number=})")
with db.Session() as session: with db.Session() as session:
plr = session.get(PlaylistRows, self.playlist_rows[row_number].plrid) plr = session.get(PlaylistRows, self.playlist_rows[row_number].playlistrow_id)
if plr: if plr:
plr.track_id = None plr.track_id = None
session.commit() session.commit()
@ -1096,12 +1024,12 @@ class PlaylistModel(QAbstractTableModel):
Example: row 4 is marked as next. Row 2 is deleted. The PlaylistRows table will 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 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 contain row_number==4. This function fixes up the track_sequence row numbers by
looking up the plr_id and retrieving the row number from the database. looking up the playlistrow_id and retrieving the row number from the database.
""" """
log.debug("reset_track_sequence_row_numbers()") log.debug("reset_track_sequence_row_numbers()")
# Check the track_sequence next, current and previous plrs and # Check the track_sequence.next, current and previous plrs and
# update the row number # update the row number
with db.Session() as session: with db.Session() as session:
for ts in [ for ts in [
@ -1110,9 +1038,9 @@ class PlaylistModel(QAbstractTableModel):
track_sequence.previous, track_sequence.previous,
]: ]:
if ts and ts.playlist_id == self.playlist_id and ts.row_number: if ts and ts.playlist_id == self.playlist_id and ts.row_number:
plr = session.get(PlaylistRows, ts.plr_id) playlist_row = session.get(PlaylistRows, ts.playlistrow_id)
if plr and plr.plr_rownum != ts.row_number: if playlist_row and playlist_row.row_number != ts.row_number:
ts.row_number = plr.plr_rownum ts.row_number = playlist_row.row_number
self.update_track_times() self.update_track_times()
@ -1152,7 +1080,7 @@ class PlaylistModel(QAbstractTableModel):
return len(self.playlist_rows) return len(self.playlist_rows)
def section_subtotal_header(self, prd: _PlaylistRowData) -> str: def section_subtotal_header(self, rat: RowAndTrack) -> str:
""" """
Process this row as subtotal within a timed section and Process this row as subtotal within a timed section and
return display text for this row return display text for this row
@ -1163,13 +1091,13 @@ class PlaylistModel(QAbstractTableModel):
duration: int = 0 duration: int = 0
# Show subtotal # Show subtotal
for row_number in range(prd.plr_rownum - 1, -1, -1): for row_number in range(rat.row_number - 1, -1, -1):
row_prd = self.playlist_rows[row_number] row_rat = self.playlist_rows[row_number]
if self.is_header_row(row_number): if self.is_header_row(row_number):
if row_prd.note.endswith("-"): if row_rat.note.endswith("-"):
# There was no start of section # There was no start of section
return prd.note return rat.note
if row_prd.note.endswith(("+", "=")): if row_rat.note.endswith(("+", "=")):
# If we are playing this section, also # If we are playing this section, also
# calculate end time if all tracks are played. # calculate end time if all tracks are played.
end_time_str = "" end_time_str = ""
@ -1179,7 +1107,7 @@ class PlaylistModel(QAbstractTableModel):
and ( and (
row_number row_number
< track_sequence.current.row_number < track_sequence.current.row_number
< prd.plr_rownum < rat.row_number
) )
): ):
section_end_time = ( section_end_time = (
@ -1190,7 +1118,7 @@ class PlaylistModel(QAbstractTableModel):
", section end time " ", section end time "
+ section_end_time.strftime(Config.TRACK_TIME_FORMAT) + section_end_time.strftime(Config.TRACK_TIME_FORMAT)
) )
stripped_note = prd.note[:-1].strip() stripped_note = rat.note[:-1].strip()
if stripped_note: if stripped_note:
return ( return (
f"{stripped_note} [" f"{stripped_note} ["
@ -1206,12 +1134,12 @@ class PlaylistModel(QAbstractTableModel):
continue continue
else: else:
count += 1 count += 1
if not row_prd.played: if not row_rat.played:
unplayed_count += 1 unplayed_count += 1
duration += row_prd.duration duration += row_rat.duration
# Should never get here # Should never get here
return f"Error calculating subtotal ({row_prd.note})" return f"Error calculating subtotal ({row_rat.note})"
def selection_is_sortable(self, row_numbers: list[int]) -> bool: def selection_is_sortable(self, row_numbers: list[int]) -> bool:
""" """
@ -1247,26 +1175,26 @@ class PlaylistModel(QAbstractTableModel):
if row_number is None: if row_number is None:
# Clear next track # Clear next track
if track_sequence.next: if track_sequence.next is not None:
track_sequence.next = None track_sequence.next = None
else: else:
return True return True
else: else:
# Get plrid of row # Get playlistrow_id of row
try: try:
prd = self.playlist_rows[row_number] rat = self.playlist_rows[row_number]
except IndexError: except IndexError:
log.error( log.error(
f"playlistmodel.set_next_track({row_number=}, " f"playlistmodel.set_track_sequence.next({row_number=}, "
f"{self.playlist_id=}" f"{self.playlist_id=}"
"IndexError" "IndexError"
) )
return False return False
if prd.track_id is None or prd.plr_rownum is None: if rat.track_id is None or rat.row_number is None:
log.error( log.error(
f"playlistmodel.set_next_track({row_number=}, " f"playlistmodel.set_track_sequence.next({row_number=}, "
"No track / row number " "No track / row number "
f"{self.playlist_id=}, {prd.track_id=}, {prd.plr_rownum=}" f"{self.playlist_id=}, {rat.track_id=}, {rat.row_number=}"
) )
return False return False
@ -1274,13 +1202,9 @@ class PlaylistModel(QAbstractTableModel):
if track_sequence.next: if track_sequence.next:
old_next_row = track_sequence.next.row_number old_next_row = track_sequence.next.row_number
with db.Session() as session: track_sequence.next = rat
try: track_sequence.next.create_fade_graph()
track_sequence.next = MainTrackManager(session, prd.plrid) self.invalidate_row(row_number)
self.invalidate_row(row_number)
except ValueError as e:
log.error(f"Error creating MainTrackManager({prd=}): ({str(e)})")
return False
if Config.WIKIPEDIA_ON_NEXT: if Config.WIKIPEDIA_ON_NEXT:
self.signals.search_wikipedia_signal.emit( self.signals.search_wikipedia_signal.emit(
@ -1314,7 +1238,7 @@ class PlaylistModel(QAbstractTableModel):
column = index.column() column = index.column()
with db.Session() as session: with db.Session() as session:
plr = session.get(PlaylistRows, self.playlist_rows[row_number].plrid) plr = session.get(PlaylistRows, self.playlist_rows[row_number].playlistrow_id)
if not plr: if not plr:
print( print(
f"Error saving data: {row_number=}, {column=}, " f"Error saving data: {row_number=}, {column=}, "
@ -1370,7 +1294,7 @@ class PlaylistModel(QAbstractTableModel):
# interested in # interested in
shortlist_rows = {k: self.playlist_rows[k] for k in row_numbers} shortlist_rows = {k: self.playlist_rows[k] for k in row_numbers}
sorted_list = [ sorted_list = [
plr.plr_rownum plr.row_number
for plr in sorted(shortlist_rows.values(), key=attrgetter(attr_name)) for plr in sorted(shortlist_rows.values(), key=attrgetter(attr_name))
] ]
self.move_rows(sorted_list, min(sorted_list)) self.move_rows(sorted_list, min(sorted_list))
@ -1404,7 +1328,7 @@ class PlaylistModel(QAbstractTableModel):
self.sort_by_attribute(row_numbers, "title") self.sort_by_attribute(row_numbers, "title")
def start_of_timed_section_header(self, prd: _PlaylistRowData) -> str: def start_of_timed_section_header(self, rat: RowAndTrack) -> str:
""" """
Process this row as the start of a timed section and Process this row as the start of a timed section and
return display text for this row return display text for this row
@ -1414,23 +1338,23 @@ class PlaylistModel(QAbstractTableModel):
unplayed_count: int = 0 unplayed_count: int = 0
duration: int = 0 duration: int = 0
for row_number in range(prd.plr_rownum + 1, len(self.playlist_rows)): for row_number in range(rat.row_number + 1, len(self.playlist_rows)):
row_prd = self.playlist_rows[row_number] row_rat = self.playlist_rows[row_number]
if self.is_header_row(row_number): if self.is_header_row(row_number):
if row_prd.note.endswith("-"): if row_rat.note.endswith("-"):
return ( return (
f"{prd.note[:-1].strip()} " f"{rat.note[:-1].strip()} "
f"[{count} tracks, {ms_to_mmss(duration)} unplayed]" f"[{count} tracks, {ms_to_mmss(duration)} unplayed]"
) )
else: else:
continue continue
else: else:
count += 1 count += 1
if not row_prd.played: if not row_rat.played:
unplayed_count += 1 unplayed_count += 1
duration += row_prd.duration duration += row_rat.duration
return ( return (
f"{prd.note[:-1].strip()} " f"{rat.note[:-1].strip()} "
f"[{count} tracks, {ms_to_mmss(duration, none='none')} " f"[{count} tracks, {ms_to_mmss(duration, none='none')} "
"unplayed (to end of playlist)]" "unplayed (to end of playlist)]"
) )
@ -1438,7 +1362,7 @@ class PlaylistModel(QAbstractTableModel):
def supportedDropActions(self) -> Qt.DropAction: def supportedDropActions(self) -> Qt.DropAction:
return Qt.DropAction.MoveAction | Qt.DropAction.CopyAction return Qt.DropAction.MoveAction | Qt.DropAction.CopyAction
def tooltip_role(self, row: int, column: int, prd: _PlaylistRowData) -> QVariant: def tooltip_role(self, row: int, column: int, rat: RowAndTrack) -> QVariant:
""" """
Return tooltip. Currently only used for last_played column. Return tooltip. Currently only used for last_played column.
""" """
@ -1472,12 +1396,15 @@ class PlaylistModel(QAbstractTableModel):
current_track_row = None current_track_row = None
next_track_row = None next_track_row = None
if track_sequence.current and track_sequence.current.playlist_id == self.playlist_id: if (
track_sequence.current
and track_sequence.current.playlist_id == self.playlist_id
):
current_track_row = track_sequence.current.row_number current_track_row = track_sequence.current.row_number
# Update current track details now so that they are available # Update current track details now so that they are available
# when we deal with next track row which may be above current # when we deal with next track row which may be above current
# track row. # track row.
self.playlist_rows[current_track_row].set_start( self.playlist_rows[current_track_row].set_forecast_start_time(
update_rows, track_sequence.current.start_time update_rows, track_sequence.current.start_time
) )
@ -1485,20 +1412,20 @@ class PlaylistModel(QAbstractTableModel):
next_track_row = track_sequence.next.row_number next_track_row = track_sequence.next.row_number
for row_number in range(row_count): for row_number in range(row_count):
prd = self.playlist_rows[row_number] rat = self.playlist_rows[row_number]
# Don't update times for tracks that have been played, for # Don't update times for tracks that have been played, for
# unreadable tracks or for the current track, handled above. # unreadable tracks or for the current track, handled above.
if ( if (
prd.played rat.played
or row_number == current_track_row or row_number == current_track_row
or (prd.path and file_is_unreadable(prd.path)) or (rat.path and file_is_unreadable(rat.path))
): ):
continue continue
# Reset start time if timing in header # Reset start time if timing in header
if self.is_header_row(row_number): if self.is_header_row(row_number):
header_time = get_embedded_time(prd.note) header_time = get_embedded_time(rat.note)
if header_time: if header_time:
next_start_time = header_time next_start_time = header_time
continue continue
@ -1509,7 +1436,7 @@ class PlaylistModel(QAbstractTableModel):
and track_sequence.current and track_sequence.current
and track_sequence.current.end_time and track_sequence.current.end_time
): ):
next_start_time = prd.set_start( next_start_time = rat.set_forecast_start_time(
update_rows, track_sequence.current.end_time update_rows, track_sequence.current.end_time
) )
continue continue
@ -1517,11 +1444,11 @@ class PlaylistModel(QAbstractTableModel):
# If we're between the current and next row, zero out # If we're between the current and next row, zero out
# times # times
if (current_track_row or row_count) < row_number < (next_track_row or 0): if (current_track_row or row_count) < row_number < (next_track_row or 0):
prd.set_start(update_rows, None) rat.set_forecast_start_time(update_rows, None)
continue continue
# Set start/end # Set start/end
next_start_time = prd.set_start(update_rows, next_start_time) next_start_time = rat.set_forecast_start_time(update_rows, next_start_time)
# Update start/stop times of rows that have changed # Update start/stop times of rows that have changed
for updated_row in update_rows: for updated_row in update_rows:
@ -1643,7 +1570,7 @@ class PlaylistProxyModel(QSortFilterProxyModel):
def get_rows_duration(self, row_numbers: list[int]) -> int: def get_rows_duration(self, row_numbers: list[int]) -> int:
return self.source_model.get_rows_duration(row_numbers) return self.source_model.get_rows_duration(row_numbers)
def get_row_info(self, row_number: int) -> _PlaylistRowData: def get_row_info(self, row_number: int) -> RowAndTrack:
return self.source_model.get_row_info(row_number) return self.source_model.get_row_info(row_number)
def get_row_track_path(self, row_number: int) -> str: def get_row_track_path(self, row_number: int) -> str:
@ -1669,7 +1596,7 @@ class PlaylistProxyModel(QSortFilterProxyModel):
def is_played_row(self, row_number: int) -> bool: def is_played_row(self, row_number: int) -> bool:
return self.source_model.is_played_row(row_number) return self.source_model.is_played_row(row_number)
def is_track_in_playlist(self, track_id: int) -> Optional[_PlaylistRowData]: def is_track_in_playlist(self, track_id: int) -> Optional[RowAndTrack]:
return self.source_model.is_track_in_playlist(track_id) return self.source_model.is_track_in_playlist(track_id)
def mark_unplayed(self, row_numbers: list[int]) -> None: def mark_unplayed(self, row_numbers: list[int]) -> None:
@ -1686,18 +1613,18 @@ class PlaylistProxyModel(QSortFilterProxyModel):
) )
def move_track_add_note( def move_track_add_note(
self, new_row_number: int, existing_prd: _PlaylistRowData, note: str self, new_row_number: int, existing_rat: RowAndTrack, note: str
) -> None: ) -> None:
return self.source_model.move_track_add_note(new_row_number, existing_prd, note) return self.source_model.move_track_add_note(new_row_number, existing_rat, note)
def move_track_to_header( def move_track_to_header(
self, self,
header_row_number: int, header_row_number: int,
existing_prd: _PlaylistRowData, existing_rat: RowAndTrack,
note: Optional[str], note: Optional[str],
) -> None: ) -> None:
return self.source_model.move_track_to_header( return self.source_model.move_track_to_header(
header_row_number, existing_prd, note header_row_number, existing_rat, note
) )
def previous_track_ended(self) -> None: def previous_track_ended(self) -> None:

View File

@ -35,7 +35,7 @@ from PyQt6.QtWidgets import (
# Third party imports # Third party imports
# App imports # App imports
from classes import Col, MusicMusterSignals, TrackInfo from classes import Col, MusicMusterSignals, TrackInfo, track_sequence
from config import Config from config import Config
from dialogs import TrackSelectDialog from dialogs import TrackSelectDialog
from helpers import ( from helpers import (
@ -47,7 +47,6 @@ from helpers import (
from log import log from log import log
from models import db, Settings from models import db, Settings
from playlistmodel import PlaylistModel, PlaylistProxyModel from playlistmodel import PlaylistModel, PlaylistProxyModel
from trackmanager import track_sequence
if TYPE_CHECKING: if TYPE_CHECKING:
from musicmuster import Window from musicmuster import Window

View File

@ -1,720 +0,0 @@
# Standard library imports
from __future__ import annotations
import ctypes
import datetime as dt
import platform
import threading
from time import sleep
from typing import Optional
# Third party imports
import numpy as np
import pyqtgraph as pg # type: ignore
import vlc # type: ignore
# PyQt imports
from PyQt6.QtCore import (
pyqtSignal,
QObject,
QRunnable,
QThread,
QThreadPool,
)
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 MusicMusterSignals
from config import Config
from log import log
from models import db, PlaylistRows, Tracks
from helpers import (
file_is_unreadable,
get_audio_segment,
show_warning,
)
lock = threading.Lock()
class _AddFadeCurve(QObject):
"""
Initialising a fade curve introduces a noticeable delay so carry out in
a thread.
"""
finished = pyqtSignal()
def __init__(
self,
track_manager: _TrackManager,
track_path: str,
track_fade_at: int,
track_silence_at: int,
) -> None:
super().__init__()
self.track_manager = track_manager
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.track_manager.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 = 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
self.audio_segment = audio[self.start_ms : self.end_ms]
self.graph_array = np.array(self.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
log.debug("issue223: _FadeCurve: create region")
self.region = pg.LinearRegionItem([0, 0], bounds=[0, len(self.graph_array)])
self.GraphWidget.addItem(self.region)
# Update region position
if self.region:
log.debug("issue223: _FadeCurve: update region")
self.region.setRegion([0, ms_of_graph * self.ms_to_array_factor])
class _FadeTrack(QRunnable):
def __init__(self, player: vlc.MediaPlayer, fade_seconds: int) -> None:
super().__init__()
self.player = player
self.fade_seconds = fade_seconds
def run(self) -> None:
"""
Implementation of fading the player
"""
if not self.player:
return
# Reduce volume logarithmically
total_steps = self.fade_seconds * Config.FADEOUT_STEPS_PER_SECOND
db_reduction_per_step = Config.FADEOUT_DB / total_steps
reduction_factor_per_step = pow(10, (db_reduction_per_step / 20))
volume = self.player.audio_get_volume()
for i in range(1, total_steps + 1):
self.player.audio_set_volume(
int(volume * pow(reduction_factor_per_step, i))
)
sleep(1 / Config.FADEOUT_STEPS_PER_SECOND)
self.player.stop()
# Define the VLC callback function type
VLC_LOG_CB = ctypes.CFUNCTYPE(
None,
ctypes.c_void_p,
ctypes.c_int,
ctypes.c_void_p,
ctypes.c_char_p,
ctypes.c_void_p,
)
# Determine the correct C library for vsnprintf based on the platform
if platform.system() == "Windows":
libc = ctypes.CDLL("msvcrt")
elif platform.system() == "Linux":
libc = ctypes.CDLL("libc.so.6")
elif platform.system() == "Darwin": # macOS
libc = ctypes.CDLL("libc.dylib")
else:
raise OSError("Unsupported operating system")
# Define the vsnprintf function
libc.vsnprintf.argtypes = [
ctypes.c_char_p,
ctypes.c_size_t,
ctypes.c_char_p,
ctypes.c_void_p,
]
libc.vsnprintf.restype = ctypes.c_int
class _Music:
"""
Manage the playing of music tracks
"""
def __init__(self, name: str) -> None:
self.VLC = vlc.Instance()
self.VLC.set_user_agent(name, name)
self.player: Optional[vlc.MediaPlayer] = None
self.name = name
self.max_volume: int = Config.VLC_VOLUME_DEFAULT
self.start_dt: Optional[dt.datetime] = None
self.player_count: int = 0
# Set up logging
self._set_vlc_log()
@VLC_LOG_CB
def log_callback(data, level, ctx, fmt, args):
try:
# Create a ctypes string buffer to hold the formatted message
buf = ctypes.create_string_buffer(1024)
# Use vsnprintf to format the string with the va_list
libc.vsnprintf(buf, len(buf), fmt, args)
# Decode the formatted message
message = buf.value.decode("utf-8", errors="replace")
log.error("VLC logging: " + message)
except Exception as e:
log.error(f"Error in VLC log callback: {e}")
def _set_vlc_log(self):
try:
vlc.libvlc_log_set(self.VLC, self.log_callback, None)
log.info("VLC logging set up successfully")
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 stop(self) -> None:
"""Immediately stop playing"""
log.debug(f"Music[{self.name}].stop()")
self.start_dt = None
if not self.player:
return
p = self.player
self.player = None
self.start_dt = None
with lock:
p.stop()
p.release()
self.player_count -= 1
log.debug(f"_Music.stop: Releasing player {p=}, {self.player_count=}")
p = None
def fade(self, fade_seconds: int) -> None:
"""
Fade the currently playing track.
The actual management of fading runs in its own thread so as not
to hold up the UI during the fade.
"""
if not self.player:
return
if not self.player.get_position() > 0 and self.player.is_playing():
return
if fade_seconds <= 0:
self.stop()
return
# Take a copy of current player to allow another track to be
# started without interfering here
with lock:
p = self.player
self.player = None
pool = QThreadPool.globalInstance()
if pool:
fader = _FadeTrack(p, fade_seconds=fade_seconds)
pool.start(fader)
self.start_dt = None
else:
log.error("_Music: failed to allocate QThreadPool")
def get_playtime(self) -> int:
"""
Return number of milliseconds current track has been playing or
zero if not playing. The vlc function get_time() only updates 3-4
times a second; this function has much better resolution.
"""
if self.start_dt is None:
return 0
now = dt.datetime.now()
elapsed_seconds = (now - self.start_dt).total_seconds()
return int(elapsed_seconds * 1000)
def get_position(self) -> Optional[float]:
"""Return current position"""
if not self.player:
return None
return self.player.get_position()
def is_playing(self) -> bool:
"""
Return True if we're playing
"""
if not self.player:
return False
# There is a discrete time between starting playing a track and
# player.is_playing() returning True, so assume playing if less
# than Config.PLAY_SETTLE microseconds have passed since
# starting play.
return self.start_dt is not None and (
self.player.is_playing()
or (dt.datetime.now() - self.start_dt)
< dt.timedelta(microseconds=Config.PLAY_SETTLE)
)
def play(
self,
path: str,
start_time: dt.datetime,
position: Optional[float] = None,
) -> None:
"""
Start playing the track at path.
Log and return if path not found.
start_time ensures our version and our caller's version of
the start time is the same
"""
log.debug(f"Music[{self.name}].play({path=}, {position=}")
if file_is_unreadable(path):
log.error(f"play({path}): path not readable")
return None
media = self.VLC.media_new_path(path)
if media is None:
log.error(f"_Music:play: failed to create media ({path=})")
show_warning(None, "Error loading file", f"Cannot play file ({path})")
return
self.player = media.player_new_from_media()
if self.player:
_ = self.player.play()
self.set_volume(self.max_volume)
self.player_count += 1
log.debug(f"_Music.play: {self.player_count=}")
if position:
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.
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)
else:
log.error("_Music:play: failed to create media player")
show_warning(None, "Media player", "Unable to create media player")
def set_position(self, position: float) -> None:
"""
Set player position
"""
if self.player:
self.player.set_position(position)
def set_volume(
self, volume: Optional[int] = None, set_default: bool = True
) -> None:
"""Set maximum volume used for player"""
if not self.player:
return
if set_default and volume:
self.max_volume = volume
if volume is None:
volume = Config.VLC_VOLUME_DEFAULT
self.player.audio_set_volume(volume)
# Ensure volume correct
# 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).
for _ in range(3):
current_volume = self.player.audio_get_volume()
if current_volume < volume:
self.player.audio_set_volume(volume)
log.debug(f"Reset from {volume=}")
sleep(0.1)
class _TrackManager:
"""
Object to manage active playlist tracks,
typically the previous, current and next track.
"""
def __init__(
self,
session: db.Session,
player_name: str,
track_id: int,
row_number: int,
) -> None:
"""
Initialises data structure.
Define a player.
Raise ValueError if no track in passed plr.
"""
track = session.get(Tracks, track_id)
if not track:
raise ValueError(f"_TrackPlayer: unable to retreived {track_id=}")
self.player_name = player_name
self.row_number = row_number
# Check file readable
if file_is_unreadable(track.path):
raise ValueError(f"_TrackManager.__init__: {track.path=} unreadable")
self.artist = track.artist
self.bitrate = track.bitrate
self.duration = track.duration
self.fade_at = track.fade_at
self.intro = track.intro
self.path = track.path
self.silence_at = track.silence_at
self.start_gap = track.start_gap
self.title = track.title
self.track_id = track.id
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]
self.start_time: Optional[dt.datetime] = None
self.end_of_track_signalled: bool = False
self.signals = MusicMusterSignals()
# Initialise player
self.player = _Music(name=player_name)
# Initialise and add FadeCurve in a thread as it's slow
self.fadecurve_thread = QThread()
self.worker = _AddFadeCurve(
self,
track_path=track.path,
track_fade_at=track.fade_at,
track_silence_at=track.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 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 not self.player.is_playing():
self.start_time = None
if self.fade_graph:
self.fade_graph.clear()
self.signal_end_of_track()
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.player.set_volume(volume=Config.VLC_VOLUME_DROP3db, set_default=False)
else:
self.player.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.player.get_position()
self.player.fade(fade_seconds)
self.signal_end_of_track()
def is_playing(self) -> bool:
"""
Return True if we're currently playing else False
"""
if self.start_time is None:
return False
return self.player.is_playing()
def move_back(self, ms: int = Config.PREVIEW_BACK_MS) -> None:
"""
Rewind player by ms milliseconds
"""
self.player.adjust_by_ms(ms * -1)
def move_forward(self, ms: int = Config.PREVIEW_ADVANCE_MS) -> None:
"""
Rewind player by ms milliseconds
"""
self.player.adjust_by_ms(ms)
def play(self, position: Optional[float] = None) -> None:
"""Play track"""
log.debug(f"issue223: _TrackManager: play {self.track_id=}")
now = dt.datetime.now()
self.start_time = now
self.player.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.player.adjust_by_ms(self.time_playing() * -1)
def signal_end_of_track(self) -> None:
"""
Send end of track signal unless we are a preview player
"""
self.signals.track_ended_signal.emit()
def stop(self, fade_seconds: int = 0) -> None:
"""
Stop this track playing
"""
self.resume_marker = self.player.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.player.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 MainTrackManager(_TrackManager):
"""
Manage playing tracks from the playlist with associated data
"""
def __init__(self, session: db.Session, plr_id: int) -> None:
"""
Set up manager for playlist tracks
"""
# Ensure we have a track
plr = session.get(PlaylistRows, plr_id)
if not plr:
raise ValueError(f"PlaylistTrack: unable to retreive plr {plr_id=}")
self.track_id: int = plr.track_id
super().__init__(
session=session,
player_name=Config.VLC_MAIN_PLAYER_NAME,
track_id=self.track_id,
row_number=plr.plr_rownum,
)
# Save non-track plr info
self.plr_id: int = plr.id
self.playlist_id: int = plr.playlist_id
def __repr__(self) -> str:
return (
f"<MainTrackManager(plr_id={self.plr_id}, playlist_id={self.playlist_id}, "
f"row_number={self.row_number}>"
)
class TrackSequence:
next: Optional[MainTrackManager] = None
current: Optional[MainTrackManager] = None
previous: Optional[MainTrackManager] = None
track_sequence = TrackSequence()

View File

@ -56,7 +56,7 @@ class TestMMMiscTracks(unittest.TestCase):
assert max(self.model.playlist_rows.keys()) == 7 assert max(self.model.playlist_rows.keys()) == 7
for row in range(self.model.rowCount()): for row in range(self.model.rowCount()):
assert row in self.model.playlist_rows assert row in self.model.playlist_rows
assert self.model.playlist_rows[row].plr_rownum == row assert self.model.playlist_rows[row].row_number == row
def test_timing_one_track(self): def test_timing_one_track(self):
START_ROW = 0 START_ROW = 0
@ -140,7 +140,7 @@ class TestMMMiscRowMove(unittest.TestCase):
# Check we have all rows and plr_rownums are correct # Check we have all rows and plr_rownums are correct
for row in range(self.model.rowCount()): for row in range(self.model.rowCount()):
assert row in self.model.playlist_rows assert row in self.model.playlist_rows
assert self.model.playlist_rows[row].plr_rownum == row assert self.model.playlist_rows[row].row_number == row
if row not in [3, 4, 5]: if row not in [3, 4, 5]:
assert self.model.playlist_rows[row].note == str(row) assert self.model.playlist_rows[row].note == str(row)
elif row == 3: elif row == 3:
@ -158,7 +158,7 @@ class TestMMMiscRowMove(unittest.TestCase):
# Check we have all rows and plr_rownums are correct # Check we have all rows and plr_rownums are correct
for row in range(self.model.rowCount()): for row in range(self.model.rowCount()):
assert row in self.model.playlist_rows assert row in self.model.playlist_rows
assert self.model.playlist_rows[row].plr_rownum == row assert self.model.playlist_rows[row].row_number == row
if row not in [3, 4]: if row not in [3, 4]:
assert self.model.playlist_rows[row].note == str(row) assert self.model.playlist_rows[row].note == str(row)
elif row == 3: elif row == 3:
@ -174,7 +174,7 @@ class TestMMMiscRowMove(unittest.TestCase):
# Check we have all rows and plr_rownums are correct # Check we have all rows and plr_rownums are correct
for row in range(self.model.rowCount()): for row in range(self.model.rowCount()):
assert row in self.model.playlist_rows assert row in self.model.playlist_rows
assert self.model.playlist_rows[row].plr_rownum == row assert self.model.playlist_rows[row].row_number == row
if row not in [2, 3, 4]: if row not in [2, 3, 4]:
assert self.model.playlist_rows[row].note == str(row) assert self.model.playlist_rows[row].note == str(row)
elif row == 2: elif row == 2:
@ -193,7 +193,7 @@ class TestMMMiscRowMove(unittest.TestCase):
new_order = [] new_order = []
for row in range(self.model.rowCount()): for row in range(self.model.rowCount()):
assert row in self.model.playlist_rows assert row in self.model.playlist_rows
assert self.model.playlist_rows[row].plr_rownum == row assert self.model.playlist_rows[row].row_number == row
new_order.append(int(self.model.playlist_rows[row].note)) new_order.append(int(self.model.playlist_rows[row].note))
assert new_order == [0, 2, 3, 6, 7, 1, 4, 5, 10, 8, 9] assert new_order == [0, 2, 3, 6, 7, 1, 4, 5, 10, 8, 9]
@ -206,7 +206,7 @@ class TestMMMiscRowMove(unittest.TestCase):
new_order = [] new_order = []
for row in range(self.model.rowCount()): for row in range(self.model.rowCount()):
assert row in self.model.playlist_rows assert row in self.model.playlist_rows
assert self.model.playlist_rows[row].plr_rownum == row assert self.model.playlist_rows[row].row_number == row
new_order.append(int(self.model.playlist_rows[row].note)) new_order.append(int(self.model.playlist_rows[row].note))
assert new_order == [0, 1, 2, 4, 3, 6, 5, 7, 8, 9, 10] assert new_order == [0, 1, 2, 4, 3, 6, 5, 7, 8, 9, 10]
@ -219,7 +219,7 @@ class TestMMMiscRowMove(unittest.TestCase):
new_order = [] new_order = []
for row in range(self.model.rowCount()): for row in range(self.model.rowCount()):
assert row in self.model.playlist_rows assert row in self.model.playlist_rows
assert self.model.playlist_rows[row].plr_rownum == row assert self.model.playlist_rows[row].row_number == row
new_order.append(int(self.model.playlist_rows[row].note)) new_order.append(int(self.model.playlist_rows[row].note))
assert new_order == [0, 1, 2, 4, 7, 3, 5, 6, 8, 9, 10] assert new_order == [0, 1, 2, 4, 7, 3, 5, 6, 8, 9, 10]
@ -232,7 +232,7 @@ class TestMMMiscRowMove(unittest.TestCase):
new_order = [] new_order = []
for row in range(self.model.rowCount()): for row in range(self.model.rowCount()):
assert row in self.model.playlist_rows assert row in self.model.playlist_rows
assert self.model.playlist_rows[row].plr_rownum == row assert self.model.playlist_rows[row].row_number == row
new_order.append(int(self.model.playlist_rows[row].note)) new_order.append(int(self.model.playlist_rows[row].note))
assert new_order == [0, 1, 2, 3, 4, 7, 8, 10, 5, 6, 9] assert new_order == [0, 1, 2, 3, 4, 7, 8, 10, 5, 6, 9]
@ -246,7 +246,7 @@ class TestMMMiscRowMove(unittest.TestCase):
new_order = [] new_order = []
for row in range(self.model.rowCount()): for row in range(self.model.rowCount()):
assert row in self.model.playlist_rows assert row in self.model.playlist_rows
assert self.model.playlist_rows[row].plr_rownum == row assert self.model.playlist_rows[row].row_number == row
new_order.append(int(self.model.playlist_rows[row].note)) new_order.append(int(self.model.playlist_rows[row].note))
assert new_order == [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10] assert new_order == [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
@ -328,7 +328,7 @@ class TestMMMiscRowMove(unittest.TestCase):
assert len(model_src.playlist_rows) == self.ROWS_TO_CREATE - len(from_rows) assert len(model_src.playlist_rows) == self.ROWS_TO_CREATE - len(from_rows)
assert len(model_dst.playlist_rows) == self.ROWS_TO_CREATE + len(from_rows) assert len(model_dst.playlist_rows) == self.ROWS_TO_CREATE + len(from_rows)
assert sorted([a.plr_rownum for a in model_src.playlist_rows.values()]) == list( assert sorted([a.row_number for a in model_src.playlist_rows.values()]) == list(
range(len(model_src.playlist_rows)) range(len(model_src.playlist_rows))
) )