diff --git a/src/jukebox/components/playermpd/__init__.py b/src/jukebox/components/playermpd/__init__.py index 49f630224..05dff82b8 100644 --- a/src/jukebox/components/playermpd/__init__.py +++ b/src/jukebox/components/playermpd/__init__.py @@ -100,6 +100,7 @@ from jukebox.NvManager import nv_manager from .playcontentcallback import PlayContentCallbacks, PlayCardState from .coverart_cache_manager import CoverartCacheManager +from .play_position_tracker import PlayPositionTracker logger = logging.getLogger('jb.PlayerMPD') cfg = jukebox.cfghandler.get_handler('jukebox') @@ -193,6 +194,9 @@ def __init__(self): # Change this to last_played_folder and shutdown_state (for restoring) self.music_player_status['player_status']['last_played_folder'] = '' + play_position_tracker_file = cfg.getn('playermpd', 'play_position_tracker_file', default='../../shared/settings/play_positions.json') + self.play_position_tracker = PlayPositionTracker(path=play_position_tracker_file) + self.old_song = None self.mpd_status = {} self.mpd_status_poll_interval = 0.25 @@ -270,6 +274,7 @@ def _mpd_status_poll(self): self.current_folder_status["LOOP"] = "OFF" self.current_folder_status["SINGLE"] = "OFF" + self.play_position_tracker.handle_mpd_status(self.mpd_status) # Delete the volume key to avoid confusion # Volume is published via the 'volume' component! try: @@ -308,11 +313,13 @@ def update_wait(self): def play(self): with self.mpd_lock: self.mpd_client.play() + self.play_position_tracker.flush() @plugs.tag def stop(self): with self.mpd_lock: self.mpd_client.stop() + self.play_position_tracker.flush() @plugs.tag def pause(self, state: int = 1): @@ -323,12 +330,14 @@ def pause(self, state: int = 1): """ with self.mpd_lock: self.mpd_client.pause(state) + self.play_position_tracker.flush() @plugs.tag def prev(self): logger.debug("Prev") with self.mpd_lock: self.mpd_client.previous() + self.play_position_tracker.flush() @plugs.tag def next(self): @@ -336,11 +345,13 @@ def next(self): logger.debug("Next") with self.mpd_lock: self.mpd_client.next() + self.play_position_tracker.flush() @plugs.tag def seek(self, new_time): with self.mpd_lock: self.mpd_client.seekcur(new_time) + self.play_position_tracker.flush() @plugs.tag def rewind(self): @@ -351,6 +362,7 @@ def rewind(self): logger.debug("Rewind") with self.mpd_lock: self.mpd_client.play(1) + self.play_position_tracker.flush() @plugs.tag def replay(self): @@ -367,6 +379,7 @@ def toggle(self): """Toggle pause state, i.e. do a pause / resume depending on current state""" with self.mpd_lock: self.mpd_client.pause() + self.play_position_tracker.flush() @plugs.tag def replay_if_stopped(self): @@ -466,11 +479,33 @@ def move(self): @plugs.tag def play_single(self, song_url): + play_target = ('single', song_url) with self.mpd_lock: + if self._play_or_pause_current(play_target): + return self.mpd_client.clear() self.mpd_client.addid(song_url) + self._mpd_restore_saved_position(play_target) self.mpd_client.play() + def _play_or_pause_current(self, play_target): + if self.play_position_tracker.is_current_play_target(play_target): + if self.mpd_status['state'] == 'play': + # Do nothing + return True + if self.mpd_status['state'] == 'pause': + logger.debug('Unpausing as the play target is identical') + self.mpd_client.play() + return True + return False + + def _mpd_restore_saved_position(self, play_target): + logger.debug(f'Restoring saved position for {play_target}') + playlist_position = self.play_position_tracker.get_playlist_position_by_play_target(play_target) or 0 + seek_position = self.play_position_tracker.get_seek_position_by_play_target(play_target) or 0 + self.play_position_tracker.set_current_play_target(play_target) + self.mpd_client.seek(playlist_position, seek_position) + @plugs.tag def resume(self): with self.mpd_lock: @@ -482,11 +517,14 @@ def resume(self): @plugs.tag def play_card(self, folder: str, recursive: bool = False): """ - Main entry point for trigger music playing from RFID reader. Decodes second swipe options before playing folder content + Deprecated (?) main entry point for trigger music playing from RFID reader. + Decodes second swipe options before playing folder content Checks for second (or multiple) trigger of the same folder and calls first swipe / second swipe action accordingly. + Note: The Web UI currently uses play_single/album/folder directly. + :param folder: Folder path relative to music library path :param recursive: Add folder recursively """ @@ -587,8 +625,11 @@ def play_folder(self, folder: str, recursive: bool = False) -> None: :param recursive: Add folder recursively """ # TODO: This changes the current state -> Need to save last state + play_target = ('folder', folder, recursive) with self.mpd_lock: logger.info(f"Play folder: '{folder}'") + if self._play_or_pause_current(play_target): + return self.mpd_client.clear() plc = playlistgenerator.PlaylistCollector(components.player.get_music_library_path()) @@ -608,6 +649,7 @@ def play_folder(self, folder: str, recursive: bool = False) -> None: if self.current_folder_status is None: self.current_folder_status = self.music_player_status['audio_folder_status'][folder] = {} + self._mpd_restore_saved_position(play_target) self.mpd_client.play() @plugs.tag @@ -621,10 +663,14 @@ def play_album(self, albumartist: str, album: str): :param albumartist: Artist of the Album provided by MPD database :param album: Album name provided by MPD database """ + play_target = ('album', albumartist, album) with self.mpd_lock: logger.info(f"Play album: '{album}' by '{albumartist}") + if self._play_or_pause_current(play_target): + return self.mpd_client.clear() self.mpd_retry_with_mutex(self.mpd_client.findadd, 'albumartist', albumartist, 'album', album) + self._mpd_restore_saved_position(play_target) self.mpd_client.play() @plugs.tag diff --git a/src/jukebox/components/playermpd/play_position_tracker.py b/src/jukebox/components/playermpd/play_position_tracker.py new file mode 100644 index 000000000..6bcdbf1d4 --- /dev/null +++ b/src/jukebox/components/playermpd/play_position_tracker.py @@ -0,0 +1,105 @@ +""" +Keeps track of playlist and in-song position for played single tracks, +albums or folders. +Syncs to disk every FLUSH_INTERVAL seconds. +Provides methods to retrieve the stored values to resume playing. +""" +import time +import os +import logging +import threading +import json + + +NO_SEEK_IF_NEAR_START_END_CUTOFF = 5 +FLUSH_INTERVAL = 30 + +logger = logging.getLogger('jb.PlayerMPD.PlayPositionTracker') + + +def play_target_to_key(play_target): + return '|'.join([str(x) for x in play_target]) + + +class PlayPositionTracker: + flush_interval = 30 + _last_flush_timestamp = 0 + + def __init__(self, path): + self._lock = threading.RLock() + self._path = path + self._tmp_path = path + '.tmp' + self._current_play_target = None + with self._lock: + self._load() + + def _load(self): + logger.debug(f'Loading from {self._path}') + try: + with open(self._path) as f: + d = json.load(f) + except FileNotFoundError: + logger.debug('File not found, assuming empty list') + self._play_targets = {} + self.flush() + return + self._play_targets = d['positions_by_play_target'] + logger.debug(f'Loaded {len(self._play_targets.keys())} saved target play positions') + + def set_current_play_target(self, play_target): + with self._lock: + self._current_play_target = play_target_to_key(play_target) + + def is_current_play_target(self, play_target): + return self._current_play_target == play_target + + def get_playlist_position_by_play_target(self, play_target): + return self._play_targets.get(play_target_to_key(play_target), {}).get('playlist_position') + + def get_seek_position_by_play_target(self, play_target): + return self._play_targets.get(play_target_to_key(play_target), {}).get('seek_position') + + def handle_mpd_status(self, status): + if not self._current_play_target: + return + playlist_position = status.get('song') + if playlist_position is not None: + with self._lock: + if self._current_play_target not in self._play_targets: + self._play_targets[self._current_play_target] = {} + self._play_targets[self._current_play_target]['playlist_position'] = playlist_position + elapsed = status.get('elapsed') + duration = status.get('duration') + if elapsed is not None: + elapsed = float(elapsed) + duration = float(duration) + if (elapsed < NO_SEEK_IF_NEAR_START_END_CUTOFF or + ((duration - elapsed) < NO_SEEK_IF_NEAR_START_END_CUTOFF)): + # restart song next time: + elapsed = 0 + with self._lock: + if self._current_play_target not in self._play_targets: + self._play_targets[self._current_play_target] = {} + self._play_targets[self._current_play_target]['seek_position'] = elapsed + self._flush_if_necessary() + + def _flush_if_necessary(self): + now = time.time() + if self._last_flush_timestamp + FLUSH_INTERVAL < now: + return self.flush() + with self._lock: + self._dirty = True + + def flush(self): + with self._lock: + self._dirty = False + self._last_flush_timestamp = time.time() + with open(self._tmp_path, 'w') as f: + json.dump({ + 'positions_by_play_target': self._play_targets, + }, f, indent=2, sort_keys=True) + os.rename(self._tmp_path, self._path) + logger.debug(f'Flushed state to {self._path}') + + def __del__(self): + self.flush()