diff --git a/app/classes.py b/app/classes.py index 64ed162..aa2ed45 100644 --- a/app/classes.py +++ b/app/classes.py @@ -1,15 +1,73 @@ # Standard library imports +from __future__ import annotations + +import ctypes from dataclasses import dataclass, field +import datetime as dt from enum import auto, Enum +import platform +import threading +from time import sleep from typing import Any, Optional, NamedTuple -# PyQt imports -from PyQt6.QtCore import pyqtSignal, QObject - # 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 -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): @@ -25,7 +83,381 @@ class Col(Enum): 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 class MusicMusterSignals(QObject): """ @@ -53,6 +485,294 @@ class MusicMusterSignals(QObject): 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"" + ) + + 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 class TrackFileData: """ @@ -70,3 +790,11 @@ class TrackFileData: class TrackInfo(NamedTuple): track_id: int row_number: int + + +class TrackSequence: + next: Optional[RowAndTrack] = None + current: Optional[RowAndTrack] = None + previous: Optional[RowAndTrack] = None + +track_sequence = TrackSequence() diff --git a/app/dbtables.py b/app/dbtables.py index 3b97d69..48f367e 100644 --- a/app/dbtables.py +++ b/app/dbtables.py @@ -76,7 +76,7 @@ class PlaylistsTable(Model): "PlaylistRowsTable", back_populates="playlist", cascade="all, delete-orphan", - order_by="PlaylistRowsTable.plr_rownum", + order_by="PlaylistRowsTable.row_number", ) def __repr__(self) -> str: @@ -90,7 +90,7 @@ class PlaylistRowsTable(Model): __tablename__ = "playlist_rows" id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) - plr_rownum: Mapped[int] + row_number: Mapped[int] note: Mapped[str] = mapped_column( String(2048), index=False, default="", nullable=False ) @@ -109,7 +109,7 @@ class PlaylistRowsTable(Model): return ( f"" + f"note={self.note}, row_number={self.row_number}>" ) @@ -135,7 +135,6 @@ class TracksTable(Model): __tablename__ = "tracks" 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) bitrate: Mapped[Optional[int]] = mapped_column(default=None) 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) silence_at: 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( "PlaylistRowsTable", back_populates="track" ) diff --git a/app/models.py b/app/models.py index 275ca74..81d3f01 100644 --- a/app/models.py +++ b/app/models.py @@ -312,7 +312,7 @@ class PlaylistRows(dbtables.PlaylistRowsTable): self.playlist_id = playlist_id self.track_id = track_id - self.plr_rownum = row_number + self.row_number = row_number self.note = note session.add(self) session.commit() @@ -338,7 +338,7 @@ class PlaylistRows(dbtables.PlaylistRowsTable): PlaylistRows( session=session, playlist_id=dst_id, - row_number=plr.plr_rownum, + row_number=plr.row_number, note=plr.note, track_id=plr.track_id, ) @@ -357,7 +357,7 @@ class PlaylistRows(dbtables.PlaylistRowsTable): .options(joinedload(cls.track)) .where( PlaylistRows.playlist_id == playlist_id, - PlaylistRows.plr_rownum == row_number, + PlaylistRows.row_number == row_number, ) # .options(joinedload(Tracks.playdates)) ) @@ -375,7 +375,7 @@ class PlaylistRows(dbtables.PlaylistRowsTable): select(PlaylistRows) .options(joinedload(cls.track)) .where(PlaylistRows.playlist_id == playlist_id) - .order_by(PlaylistRows.plr_rownum) + .order_by(PlaylistRows.row_number) # .options(joinedload(Tracks.playdates)) ) @@ -391,7 +391,7 @@ class PlaylistRows(dbtables.PlaylistRowsTable): session.execute( delete(PlaylistRows).where( PlaylistRows.playlist_id == playlist_id, - PlaylistRows.plr_rownum > maxrow, + PlaylistRows.row_number > maxrow, ) ) session.commit() @@ -405,7 +405,7 @@ class PlaylistRows(dbtables.PlaylistRowsTable): session.execute( delete(PlaylistRows).where( 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( select(PlaylistRows) .where(PlaylistRows.playlist_id == playlist_id) - .order_by(PlaylistRows.plr_rownum) + .order_by(PlaylistRows.row_number) ).all() for i, plr in enumerate(plrs): - plr.plr_rownum = i + plr.row_number = i # Ensure new row numbers are available to the caller session.commit() @@ -439,7 +439,7 @@ class PlaylistRows(dbtables.PlaylistRowsTable): plrs = session.scalars( select(cls) .where(cls.playlist_id == playlist_id, cls.id.in_(plr_ids)) - .order_by(cls.plr_rownum) + .order_by(cls.row_number) ).all() return plrs @@ -449,7 +449,7 @@ class PlaylistRows(dbtables.PlaylistRowsTable): """Return the last used row for playlist, or None if no rows""" return session.execute( - select(func.max(PlaylistRows.plr_rownum)).where( + select(func.max(PlaylistRows.row_number)).where( PlaylistRows.playlist_id == playlist_id ) ).scalar_one() @@ -481,7 +481,7 @@ class PlaylistRows(dbtables.PlaylistRowsTable): plrs = session.scalars( select(cls) .where(cls.playlist_id == playlist_id, cls.played.is_(True)) - .order_by(cls.plr_rownum) + .order_by(cls.row_number) ).all() return plrs @@ -500,7 +500,7 @@ class PlaylistRows(dbtables.PlaylistRowsTable): query = select(cls).where( 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 @@ -520,7 +520,7 @@ class PlaylistRows(dbtables.PlaylistRowsTable): cls.track_id.is_not(None), cls.played.is_(False), ) - .order_by(cls.plr_rownum) + .order_by(cls.row_number) ).all() return plrs @@ -558,17 +558,17 @@ class PlaylistRows(dbtables.PlaylistRowsTable): update(PlaylistRows) .where( (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 - def update_plr_rownumbers( + def update_plr_row_numbers( session: Session, playlist_id: int, sqla_map: List[dict[str, int]] ) -> 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: @@ -577,9 +577,9 @@ class PlaylistRows(dbtables.PlaylistRowsTable): update(PlaylistRows) .where( 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) diff --git a/app/musicmuster.py b/app/musicmuster.py index 27a76ad..f86b194 100755 --- a/app/musicmuster.py +++ b/app/musicmuster.py @@ -53,8 +53,10 @@ import stackprinter # type: ignore # App imports from classes import ( MusicMusterSignals, + RowAndTrack, TrackFileData, TrackInfo, + track_sequence, ) from config import Config from dialogs import TrackSelectDialog, ReplaceFilesDialog @@ -63,10 +65,6 @@ from log import log from models import db, Playdates, PlaylistRows, Playlists, Settings, Tracks from playlistmodel import PlaylistModel, PlaylistProxyModel from playlists import PlaylistTab -from trackmanager import ( - MainTrackManager, - track_sequence, -) from ui import icons_rc # noqa F401 from ui.dlg_SelectPlaylist_ui import Ui_dlgSelectPlaylist # type: ignore from ui.downloadcsv_ui import Ui_DateSelect # type: ignore @@ -1108,7 +1106,7 @@ class Window(QMainWindow, Ui_MainWindow): - Clear next track - Restore volume if -3dB active - Play (new) current track. - - Show closing volume graph + - Show fade graph - Notify model - Note that track is now playing - Disable play next controls @@ -1129,8 +1127,8 @@ class Window(QMainWindow, Ui_MainWindow): # Issue #223 concerns a very short pause (maybe 0.1s) sometimes # when starting to play at track. - # Resolution appears to be to disable timer10 for the first ten - # seconds of playback. Re-enable in update_clocks. + # Resolution appears to be to disable timer10 for a short time. + # Length of time and re-enabling of timer10 both in update_clocks. self.timer10.stop() 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 # track to the next track: track_sequence.next = track_sequence.previous + track_sequence.next.create_fade_graph() # Now resume playing the now-next track self.play_next(track_sequence.next.resume_marker) @@ -1569,7 +1568,7 @@ class Window(QMainWindow, Ui_MainWindow): 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""" # Switch to the correct tab @@ -1678,12 +1677,6 @@ class Window(QMainWindow, Ui_MainWindow): self.preview_manager.get_playtime() / 1000, 60 ) 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: self.btnPreview.setChecked(False) self.label_intro_timer.setText("0.0") @@ -1717,7 +1710,7 @@ class Window(QMainWindow, Ui_MainWindow): # see play_next() and issue #223. # TODO: find a better way of handling this if ( - track_sequence.current.time_playing() > 10000 + track_sequence.current.time_playing() > 5000 and not self.timer10.isActive() ): self.timer10.start(10) diff --git a/app/playlistmodel.py b/app/playlistmodel.py index 538cd6a..503c44c 100644 --- a/app/playlistmodel.py +++ b/app/playlistmodel.py @@ -31,7 +31,12 @@ import obswebsocket # type: ignore # import snoop # type: ignore # App imports -from classes import Col, MusicMusterSignals +from classes import ( + Col, + MusicMusterSignals, + RowAndTrack, + track_sequence, +) from config import Config from helpers import ( file_is_unreadable, @@ -42,92 +47,12 @@ from helpers import ( ) from log import log from models import db, NoteColours, Playdates, PlaylistRows, Tracks -from trackmanager import ( - MainTrackManager, - track_sequence, -) HEADER_NOTES_COLUMN = 1 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"" - ) - - 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): """ The Playlist Model @@ -155,7 +80,7 @@ class PlaylistModel(QAbstractTableModel): self.playlist_id = playlist_id super().__init__(*args, **kwargs) - self.playlist_rows: dict[int, _PlaylistRowData] = {} + self.playlist_rows: dict[int, RowAndTrack] = {} self.signals = MusicMusterSignals() self.played_tracks_hidden = False @@ -167,7 +92,6 @@ class PlaylistModel(QAbstractTableModel): PlaylistRows.fixup_rownumbers(session, playlist_id) # Populate self.playlist_rows self.refresh_data(session) - session.commit() self.update_track_times() def __repr__(self) -> str: @@ -186,27 +110,27 @@ class PlaylistModel(QAbstractTableModel): # Get existing row try: - prd = self.playlist_rows[row_number] + rat = self.playlist_rows[row_number] except KeyError: log.error( f"KeyError in PlaylistModel:add_track_to_header " f"{row_number=}, {track_id=}, {len(self.playlist_rows)=}" ) return - if prd.path: + if rat.path: 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" ) return with db.Session() as session: - plr = session.get(PlaylistRows, prd.plrid) - if plr: + playlistrow = session.get(PlaylistRows, rat.playlistrow_id) + if playlistrow: # Add track to PlaylistRows - plr.track_id = track_id + playlistrow.track_id = track_id # Add any further note (header will already have a note) if note: - plr.note += "\n" + note + playlistrow.note += "\n" + note # Update local copy self.refresh_row(session, row_number) # Repaint row @@ -215,7 +139,7 @@ class PlaylistModel(QAbstractTableModel): 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""" # Handle entire row colouring @@ -223,36 +147,36 @@ class PlaylistModel(QAbstractTableModel): if self.is_header_row(row): # Check for specific header colouring with db.Session() as session: - note_colour = NoteColours.get_colour(session, prd.note) + note_colour = NoteColours.get_colour(session, rat.note) if note_colour: return QBrush(QColor(note_colour)) else: return QBrush(QColor(Config.COLOUR_NOTES_PLAYLIST)) # Unreadable track file - if file_is_unreadable(prd.path): + if file_is_unreadable(rat.path): return QBrush(QColor(Config.COLOUR_UNREADABLE)) # 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)) # 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)) # Individual cell colouring 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)) 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)) - elif prd.bitrate and prd.bitrate < Config.BITRATE_OK_THRESHOLD: + elif rat.bitrate < Config.BITRATE_OK_THRESHOLD: return QBrush(QColor(Config.COLOUR_BITRATE_MEDIUM)) else: return QBrush(QColor(Config.COLOUR_BITRATE_OK)) if column == Col.NOTE.value: - if prd.note: + if rat.note: with db.Session() as session: - note_colour = NoteColours.get_colour(session, prd.note) + note_colour = NoteColours.get_colour(session, rat.note) if note_colour: return QBrush(QColor(note_colour)) @@ -296,6 +220,10 @@ class PlaylistModel(QAbstractTableModel): log.debug("Call OBS scene change") 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: # Update Playdates in database log.debug("update playdates") @@ -303,12 +231,12 @@ class PlaylistModel(QAbstractTableModel): # Mark track as played in playlist 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: plr.played = True - self.refresh_row(session, plr.plr_rownum) + self.refresh_row(session, plr.row_number) 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 self.invalidate_row(row_number) @@ -353,8 +281,8 @@ class PlaylistModel(QAbstractTableModel): row = index.row() column = index.column() - # prd for playlist row data as it's used a lot - prd = self.playlist_rows[row] + # rat for playlist row data as it's used a lot + rat = self.playlist_rows[row] # Dispatch to role-specific functions dispatch_table = { @@ -366,7 +294,7 @@ class PlaylistModel(QAbstractTableModel): } 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 if role in [ @@ -412,7 +340,7 @@ class PlaylistModel(QAbstractTableModel): 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 """ @@ -428,40 +356,40 @@ class PlaylistModel(QAbstractTableModel): if self.is_header_row(row): if column == HEADER_NOTES_COLUMN: - header_text = self.header_text(prd) + header_text = self.header_text(rat) if not header_text: return QVariant(Config.TEXT_NO_TRACK_NO_NOTE) else: - return QVariant(self.header_text(prd)) + return QVariant(self.header_text(rat)) else: return QVariant() if column == Col.START_TIME.value: - start_time = prd.start_time + start_time = rat.forecast_start_time if start_time: return QVariant(start_time.strftime(Config.TRACK_TIME_FORMAT)) return QVariant() if column == Col.END_TIME.value: - end_time = prd.end_time + end_time = rat.forecast_end_time if end_time: return QVariant(end_time.strftime(Config.TRACK_TIME_FORMAT)) return QVariant() if column == Col.INTRO.value: - if prd.intro: - return QVariant(f"{prd.intro / 1000:{Config.INTRO_SECONDS_FORMAT}}") + if rat.intro: + return QVariant(f"{rat.intro / 1000:{Config.INTRO_SECONDS_FORMAT}}") else: return QVariant() dispatch_table = { - Col.ARTIST.value: QVariant(prd.artist), - Col.BITRATE.value: QVariant(prd.bitrate), - Col.DURATION.value: QVariant(ms_to_mmss(prd.duration)), - Col.LAST_PLAYED.value: QVariant(get_relative_date(prd.lastplayed)), - Col.NOTE.value: QVariant(prd.note), - Col.START_GAP.value: QVariant(prd.start_gap), - Col.TITLE.value: QVariant(prd.title), + Col.ARTIST.value: QVariant(rat.artist), + Col.BITRATE.value: QVariant(rat.bitrate), + Col.DURATION.value: QVariant(ms_to_mmss(rat.duration)), + Col.LAST_PLAYED.value: QVariant(get_relative_date(rat.lastplayed)), + Col.NOTE.value: QVariant(rat.note), + Col.START_GAP.value: QVariant(rat.start_gap), + Col.TITLE.value: QVariant(rat.title), } if column in dispatch_table: return dispatch_table[column] @@ -483,7 +411,7 @@ class PlaylistModel(QAbstractTableModel): super().endResetModel() 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 """ @@ -491,16 +419,16 @@ class PlaylistModel(QAbstractTableModel): # If this is a header row and we're being asked for the # HEADER_NOTES_COLUMN, return the note value if self.is_header_row(row) and column == HEADER_NOTES_COLUMN: - return QVariant(prd.note) + return QVariant(rat.note) if column == Col.INTRO.value: - return QVariant(prd.intro) + return QVariant(rat.intro) if column == Col.TITLE.value: - return QVariant(prd.title) + return QVariant(rat.title) if column == Col.ARTIST.value: - return QVariant(prd.artist) + return QVariant(rat.artist) if column == Col.NOTE.value: - return QVariant(prd.note) + return QVariant(rat.note) return QVariant() @@ -527,7 +455,7 @@ class PlaylistModel(QAbstractTableModel): 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 """ @@ -586,7 +514,7 @@ class PlaylistModel(QAbstractTableModel): log.debug(f"get_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 """ @@ -624,7 +552,7 @@ class PlaylistModel(QAbstractTableModel): """ result = [ - a.plr_rownum + a.row_number for a in self.playlist_rows.values() if not a.played and a.track_id is not None ] @@ -670,31 +598,31 @@ class PlaylistModel(QAbstractTableModel): return QVariant() - def header_text(self, prd: _PlaylistRowData) -> str: + def header_text(self, rat: RowAndTrack) -> str: """ Process possible section timing directives embeded in header """ - if prd.note.endswith("+"): - return self.start_of_timed_section_header(prd) + if rat.note.endswith("+"): + return self.start_of_timed_section_header(rat) - elif prd.note.endswith("="): - return self.section_subtotal_header(prd) + elif rat.note.endswith("="): + 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 # that started the section without the trailing "+". - for row_number in range(prd.plr_rownum - 1, -1, -1): - row_prd = self.playlist_rows[row_number] + for row_number in range(rat.row_number - 1, -1, -1): + row_rat = self.playlist_rows[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 break - if row_prd.note.endswith("+"): - return f"[End: {row_prd.note[:-1]}]" + if row_rat.note.endswith("+"): + return f"[End: {row_rat.note[:-1]}]" return "-" - return prd.note + return rat.note def hide_played_tracks(self, hide: bool) -> None: """ @@ -772,7 +700,7 @@ class PlaylistModel(QAbstractTableModel): 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 else return None @@ -791,7 +719,7 @@ class PlaylistModel(QAbstractTableModel): with db.Session() as session: 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: return plr.played = False @@ -860,15 +788,15 @@ class PlaylistModel(QAbstractTableModel): 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] - # 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: sqla_map: list[dict[str, int]] = [] for oldrow, newrow in row_map.items(): - plrid = self.playlist_rows[oldrow].plrid - sqla_map.append({"plrid": plrid, "plr_rownum": newrow}) + playlistrow_id = self.playlist_rows[oldrow].playlistrow_id + sqla_map.append({"playlistrow_id": playlistrow_id, "row_number": newrow}) 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() # Update playlist_rows self.refresh_data(session) @@ -914,19 +842,19 @@ class PlaylistModel(QAbstractTableModel): for row_group in row_groups: 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, 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 ( 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 continue - plr.playlist_id = to_playlist_id - plr.plr_rownum = next_to_row + playlist_row.playlist_id = to_playlist_id + playlist_row.row_number = next_to_row next_to_row += 1 self.refresh_data(session) super().endRemoveRows() @@ -942,17 +870,17 @@ class PlaylistModel(QAbstractTableModel): self.update_track_times() 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: """ - 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: with db.Session() as session: - plr = session.get(PlaylistRows, existing_prd.plrid) + plr = session.get(PlaylistRows, existing_rat.playlistrow_id) if plr: if plr.note: plr.note += "\n" + note @@ -962,26 +890,26 @@ class PlaylistModel(QAbstractTableModel): # Carry out the move outside of the session context to ensure # 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) def move_track_to_header( self, header_row_number: int, - existing_prd: _PlaylistRowData, + existing_rat: RowAndTrack, note: Optional[str], ) -> 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 note and existing_prd.note: - note += "\n" + existing_prd.note - self.add_track_to_header(header_row_number, existing_prd.track_id, note) - self.delete_rows([existing_prd.plr_rownum]) + if existing_rat.track_id: + if note and existing_rat.note: + note += "\n" + existing_rat.note + self.add_track_to_header(header_row_number, existing_rat.track_id, note) + self.delete_rows([existing_rat.row_number]) def obs_scene_change(self, row_number: int) -> None: """ @@ -1050,13 +978,13 @@ class PlaylistModel(QAbstractTableModel): # Populate self.playlist_rows with playlist data self.playlist_rows.clear() 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): """Populate dict for one row from database""" 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: """ @@ -1066,7 +994,7 @@ class PlaylistModel(QAbstractTableModel): log.info(f"remove_track({row_number=})") 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: plr.track_id = None session.commit() @@ -1096,12 +1024,12 @@ class PlaylistModel(QAbstractTableModel): Example: row 4 is marked as next. Row 2 is deleted. The PlaylistRows table will be correctly updated with change of row number, but track_sequence.next will still contain row_number==4. This function fixes up the track_sequence row numbers by - looking up the 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()") - # Check the track_sequence next, current and previous plrs and + # Check the track_sequence.next, current and previous plrs and # update the row number with db.Session() as session: for ts in [ @@ -1110,9 +1038,9 @@ class PlaylistModel(QAbstractTableModel): track_sequence.previous, ]: if ts and ts.playlist_id == self.playlist_id and ts.row_number: - plr = session.get(PlaylistRows, ts.plr_id) - if plr and plr.plr_rownum != ts.row_number: - ts.row_number = plr.plr_rownum + playlist_row = session.get(PlaylistRows, ts.playlistrow_id) + if playlist_row and playlist_row.row_number != ts.row_number: + ts.row_number = playlist_row.row_number self.update_track_times() @@ -1152,7 +1080,7 @@ class PlaylistModel(QAbstractTableModel): 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 return display text for this row @@ -1163,13 +1091,13 @@ class PlaylistModel(QAbstractTableModel): duration: int = 0 # Show subtotal - for row_number in range(prd.plr_rownum - 1, -1, -1): - row_prd = self.playlist_rows[row_number] + for row_number in range(rat.row_number - 1, -1, -1): + row_rat = self.playlist_rows[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 - return prd.note - if row_prd.note.endswith(("+", "=")): + return rat.note + if row_rat.note.endswith(("+", "=")): # If we are playing this section, also # calculate end time if all tracks are played. end_time_str = "" @@ -1179,7 +1107,7 @@ class PlaylistModel(QAbstractTableModel): and ( row_number < track_sequence.current.row_number - < prd.plr_rownum + < rat.row_number ) ): section_end_time = ( @@ -1190,7 +1118,7 @@ class PlaylistModel(QAbstractTableModel): ", section end time " + section_end_time.strftime(Config.TRACK_TIME_FORMAT) ) - stripped_note = prd.note[:-1].strip() + stripped_note = rat.note[:-1].strip() if stripped_note: return ( f"{stripped_note} [" @@ -1206,12 +1134,12 @@ class PlaylistModel(QAbstractTableModel): continue else: count += 1 - if not row_prd.played: + if not row_rat.played: unplayed_count += 1 - duration += row_prd.duration + duration += row_rat.duration # 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: """ @@ -1247,26 +1175,26 @@ class PlaylistModel(QAbstractTableModel): if row_number is None: # Clear next track - if track_sequence.next: + if track_sequence.next is not None: track_sequence.next = None else: return True else: - # Get plrid of row + # Get playlistrow_id of row try: - prd = self.playlist_rows[row_number] + rat = self.playlist_rows[row_number] except IndexError: log.error( - f"playlistmodel.set_next_track({row_number=}, " + f"playlistmodel.set_track_sequence.next({row_number=}, " f"{self.playlist_id=}" "IndexError" ) 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( - f"playlistmodel.set_next_track({row_number=}, " + f"playlistmodel.set_track_sequence.next({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 @@ -1274,13 +1202,9 @@ class PlaylistModel(QAbstractTableModel): if track_sequence.next: old_next_row = track_sequence.next.row_number - with db.Session() as session: - try: - track_sequence.next = MainTrackManager(session, prd.plrid) - self.invalidate_row(row_number) - except ValueError as e: - log.error(f"Error creating MainTrackManager({prd=}): ({str(e)})") - return False + track_sequence.next = rat + track_sequence.next.create_fade_graph() + self.invalidate_row(row_number) if Config.WIKIPEDIA_ON_NEXT: self.signals.search_wikipedia_signal.emit( @@ -1314,7 +1238,7 @@ class PlaylistModel(QAbstractTableModel): column = index.column() 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: print( f"Error saving data: {row_number=}, {column=}, " @@ -1370,7 +1294,7 @@ class PlaylistModel(QAbstractTableModel): # interested in shortlist_rows = {k: self.playlist_rows[k] for k in row_numbers} sorted_list = [ - plr.plr_rownum + plr.row_number for plr in sorted(shortlist_rows.values(), key=attrgetter(attr_name)) ] self.move_rows(sorted_list, min(sorted_list)) @@ -1404,7 +1328,7 @@ class PlaylistModel(QAbstractTableModel): 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 return display text for this row @@ -1414,23 +1338,23 @@ class PlaylistModel(QAbstractTableModel): unplayed_count: int = 0 duration: int = 0 - for row_number in range(prd.plr_rownum + 1, len(self.playlist_rows)): - row_prd = self.playlist_rows[row_number] + for row_number in range(rat.row_number + 1, len(self.playlist_rows)): + row_rat = self.playlist_rows[row_number] if self.is_header_row(row_number): - if row_prd.note.endswith("-"): + if row_rat.note.endswith("-"): return ( - f"{prd.note[:-1].strip()} " + f"{rat.note[:-1].strip()} " f"[{count} tracks, {ms_to_mmss(duration)} unplayed]" ) else: continue else: count += 1 - if not row_prd.played: + if not row_rat.played: unplayed_count += 1 - duration += row_prd.duration + duration += row_rat.duration return ( - f"{prd.note[:-1].strip()} " + f"{rat.note[:-1].strip()} " f"[{count} tracks, {ms_to_mmss(duration, none='none')} " "unplayed (to end of playlist)]" ) @@ -1438,7 +1362,7 @@ class PlaylistModel(QAbstractTableModel): def supportedDropActions(self) -> Qt.DropAction: 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. """ @@ -1472,12 +1396,15 @@ class PlaylistModel(QAbstractTableModel): current_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 # Update current track details now so that they are available # when we deal with next track row which may be above current # track row. - self.playlist_rows[current_track_row].set_start( + self.playlist_rows[current_track_row].set_forecast_start_time( update_rows, track_sequence.current.start_time ) @@ -1485,20 +1412,20 @@ class PlaylistModel(QAbstractTableModel): next_track_row = track_sequence.next.row_number 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 # unreadable tracks or for the current track, handled above. if ( - prd.played + rat.played 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 # Reset start time if timing in header if self.is_header_row(row_number): - header_time = get_embedded_time(prd.note) + header_time = get_embedded_time(rat.note) if header_time: next_start_time = header_time continue @@ -1509,7 +1436,7 @@ class PlaylistModel(QAbstractTableModel): and track_sequence.current 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 ) continue @@ -1517,11 +1444,11 @@ class PlaylistModel(QAbstractTableModel): # If we're between the current and next row, zero out # times 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 # 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 for updated_row in update_rows: @@ -1643,7 +1570,7 @@ class PlaylistProxyModel(QSortFilterProxyModel): def get_rows_duration(self, row_numbers: list[int]) -> int: 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) 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: 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) def mark_unplayed(self, row_numbers: list[int]) -> None: @@ -1686,18 +1613,18 @@ class PlaylistProxyModel(QSortFilterProxyModel): ) 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: - 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( self, header_row_number: int, - existing_prd: _PlaylistRowData, + existing_rat: RowAndTrack, note: Optional[str], ) -> None: 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: diff --git a/app/playlists.py b/app/playlists.py index c33ac3d..1b76be2 100644 --- a/app/playlists.py +++ b/app/playlists.py @@ -35,7 +35,7 @@ from PyQt6.QtWidgets import ( # Third party imports # App imports -from classes import Col, MusicMusterSignals, TrackInfo +from classes import Col, MusicMusterSignals, TrackInfo, track_sequence from config import Config from dialogs import TrackSelectDialog from helpers import ( @@ -47,7 +47,6 @@ from helpers import ( from log import log from models import db, Settings from playlistmodel import PlaylistModel, PlaylistProxyModel -from trackmanager import track_sequence if TYPE_CHECKING: from musicmuster import Window diff --git a/app/trackmanager.py b/app/trackmanager.py deleted file mode 100644 index 02217e5..0000000 --- a/app/trackmanager.py +++ /dev/null @@ -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"" - ) - - -class TrackSequence: - next: Optional[MainTrackManager] = None - current: Optional[MainTrackManager] = None - previous: Optional[MainTrackManager] = None - - -track_sequence = TrackSequence() diff --git a/tests/test_playlistmodel.py b/tests/test_playlistmodel.py index c697f64..eddc8f9 100644 --- a/tests/test_playlistmodel.py +++ b/tests/test_playlistmodel.py @@ -56,7 +56,7 @@ class TestMMMiscTracks(unittest.TestCase): assert max(self.model.playlist_rows.keys()) == 7 for row in range(self.model.rowCount()): 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): START_ROW = 0 @@ -140,7 +140,7 @@ class TestMMMiscRowMove(unittest.TestCase): # Check we have all rows and plr_rownums are correct for row in range(self.model.rowCount()): assert row in self.model.playlist_rows - assert self.model.playlist_rows[row].plr_rownum == row + assert self.model.playlist_rows[row].row_number == row if row not in [3, 4, 5]: assert self.model.playlist_rows[row].note == str(row) elif row == 3: @@ -158,7 +158,7 @@ class TestMMMiscRowMove(unittest.TestCase): # Check we have all rows and plr_rownums are correct for row in range(self.model.rowCount()): assert row in self.model.playlist_rows - assert self.model.playlist_rows[row].plr_rownum == row + assert self.model.playlist_rows[row].row_number == row if row not in [3, 4]: assert self.model.playlist_rows[row].note == str(row) elif row == 3: @@ -174,7 +174,7 @@ class TestMMMiscRowMove(unittest.TestCase): # Check we have all rows and plr_rownums are correct for row in range(self.model.rowCount()): assert row in self.model.playlist_rows - assert self.model.playlist_rows[row].plr_rownum == row + assert self.model.playlist_rows[row].row_number == row if row not in [2, 3, 4]: assert self.model.playlist_rows[row].note == str(row) elif row == 2: @@ -193,7 +193,7 @@ class TestMMMiscRowMove(unittest.TestCase): new_order = [] for row in range(self.model.rowCount()): 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)) assert new_order == [0, 2, 3, 6, 7, 1, 4, 5, 10, 8, 9] @@ -206,7 +206,7 @@ class TestMMMiscRowMove(unittest.TestCase): new_order = [] for row in range(self.model.rowCount()): 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)) assert new_order == [0, 1, 2, 4, 3, 6, 5, 7, 8, 9, 10] @@ -219,7 +219,7 @@ class TestMMMiscRowMove(unittest.TestCase): new_order = [] for row in range(self.model.rowCount()): 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)) assert new_order == [0, 1, 2, 4, 7, 3, 5, 6, 8, 9, 10] @@ -232,7 +232,7 @@ class TestMMMiscRowMove(unittest.TestCase): new_order = [] for row in range(self.model.rowCount()): 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)) assert new_order == [0, 1, 2, 3, 4, 7, 8, 10, 5, 6, 9] @@ -246,7 +246,7 @@ class TestMMMiscRowMove(unittest.TestCase): new_order = [] for row in range(self.model.rowCount()): 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)) 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_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)) )