diff --git a/documentation/developers/status.md b/documentation/developers/status.md index 0a40f8125..0740fb175 100644 --- a/documentation/developers/status.md +++ b/documentation/developers/status.md @@ -106,7 +106,8 @@ Topics marked _in progress_ are already in the process of implementation by comm - [ ] Folder configuration (_in progress_) - [ ] [Reference](https://github.com/MiczFlor/RPi-Jukebox-RFID/wiki/MANUAL#manage-playout-behaviour) - - [ ] Resume: Save and restore position (how interact with shuffle?) + - [x] Resume: Save and restore position + - [ ] Resume during shuffle: How to interact? - [ ] Repeat Playlist - [ ] Repeat Song - [ ] Shuffle diff --git a/resources/default-settings/jukebox.default.yaml b/resources/default-settings/jukebox.default.yaml index c087cc024..1745eebca 100644 --- a/resources/default-settings/jukebox.default.yaml +++ b/resources/default-settings/jukebox.default.yaml @@ -87,6 +87,10 @@ playermpd: update_on_startup: true check_user_rights: true mpd_conf: ~/.config/mpd/mpd.conf + resume: + resume_by_default: false + file: ../../shared/logs/resume_positions.json + flush_interval_seconds: 30 rpc: tcp_port: 5555 websocket_port: 5556 diff --git a/src/jukebox/components/playermpd/__init__.py b/src/jukebox/components/playermpd/__init__.py index dcbef2ea8..777413ccf 100644 --- a/src/jukebox/components/playermpd/__init__.py +++ b/src/jukebox/components/playermpd/__init__.py @@ -87,6 +87,7 @@ import logging import time import functools +from typing import Optional from pathlib import Path import components.player import jukebox.cfghandler @@ -100,6 +101,7 @@ from jukebox.NvManager import nv_manager from .playcontentcallback import PlayContentCallbacks, PlayCardState from .coverart_cache_manager import CoverartCacheManager +from .resume_position_tracker import ResumePositionTracker logger = logging.getLogger('jb.PlayerMPD') cfg = jukebox.cfghandler.get_handler('jukebox') @@ -193,6 +195,8 @@ def __init__(self): # Change this to last_played_folder and shutdown_state (for restoring) self.music_player_status['player_status']['last_played_folder'] = '' + self.resume_position_tracker = ResumePositionTracker() + 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.resume_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.resume_position_tracker.flush() @plugs.tag def stop(self): with self.mpd_lock: self.mpd_client.stop() + self.resume_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.resume_position_tracker.flush() @plugs.tag def prev(self): logger.debug("Prev") with self.mpd_lock: self.mpd_client.previous() + self.resume_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.resume_position_tracker.flush() @plugs.tag def seek(self, new_time): with self.mpd_lock: self.mpd_client.seekcur(new_time) + self.resume_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(0) + self.resume_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.resume_position_tracker.flush() @plugs.tag def replay_if_stopped(self): @@ -465,12 +478,35 @@ def move(self): raise NotImplementedError @plugs.tag - def play_single(self, song_url): + def play_single(self, song_url, resume=None): + 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_resume_from_saved_position(play_target, resume) self.mpd_client.play() + def _play_or_pause_current(self, play_target): + if self.resume_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_resume_from_saved_position(self, play_target, resume: Optional[bool]): + playlist_position = self.resume_position_tracker.get_playlist_position_by_play_target(play_target) or 0 + seek_position = self.resume_position_tracker.get_seek_position_by_play_target(play_target) or 0 + self.resume_position_tracker.set_current_play_target(play_target) + if resume or (resume is None and self.resume_position_tracker.resume_by_default): + logger.debug(f'Restoring saved position for {play_target}') + self.mpd_client.seek(playlist_position, seek_position) + @plugs.tag def resume(self): with self.mpd_lock: @@ -482,11 +518,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 """ @@ -554,7 +593,7 @@ def get_folder_content(self, folder: str): return plc.playlist @plugs.tag - def play_folder(self, folder: str, recursive: bool = False) -> None: + def play_folder(self, folder: str, recursive: bool = False, resume: Optional[bool] = None) -> None: """ Playback a music folder. @@ -565,8 +604,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()) @@ -586,10 +628,11 @@ 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_resume_from_saved_position(play_target, resume) self.mpd_client.play() @plugs.tag - def play_album(self, albumartist: str, album: str): + def play_album(self, albumartist: str, album: str, resume: Optional[bool] = None): """ Playback a album found in MPD database. @@ -599,10 +642,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_resume_from_saved_position(play_target, resume) 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..ebec5965b --- /dev/null +++ b/src/jukebox/components/playermpd/play_position_tracker.py @@ -0,0 +1,130 @@ +import time +import os +import logging +import threading +import json +import jukebox.cfghandler + + +NO_SEEK_IF_NEAR_START_END_CUTOFF = 5 + +logger = logging.getLogger('jb.PlayerMPD.ResumePositionTracker') +cfg = jukebox.cfghandler.get_handler('jukebox') + + +def play_target_to_key(play_target) -> str: + """ + Play targets encode how the current playlist was constructed. + play_target_to_key converts this information into a json-serializable string + """ + return '|'.join([str(x) for x in play_target]) + + +class ResumePositionTracker: + """ + Keeps track of playlist and in-song position for played single tracks, + albums or folders. + Syncs to disk every at the configured interval and on all relevant user input + (e.g. card swipes, prev, next, ...). + Provides methods to retrieve the stored values to resume playing. + """ + + _last_flush_timestamp: float = 0 + _last_json: str = '' + + def __init__(self): + self._path = cfg.getn('playermpd', 'resume', 'file', + default='../../shared/logs/resume_positions.json') + self._flush_interval = cfg.getn('playermpd', 'resume', 'flush_interval_seconds', + default=30) + self.resume_by_default = cfg.getn('playermpd', 'resume', 'resume_by_default', + default=False) + self._lock = threading.RLock() + self._tmp_path = self._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_len = int(status.get('playlistlength', -1)) + playlist_pos = int(status.get('pos', 0)) + elapsed = float(status.get('elapsed', 0)) + duration = float(status.get('duration', 0)) + is_end_of_playlist = playlist_pos == playlist_len - 1 + is_end_of_track = duration - elapsed < NO_SEEK_IF_NEAR_START_END_CUTOFF + if status.get('state') == 'stop' and is_end_of_playlist and is_end_of_track: + # If we are at the end of the playlist, + # we want to restart the playlist the next time the card is present. + # Therefore, delete all resume information: + if self._current_play_target in self._play_targets: + with self._lock: + del self._play_targets[self._current_play_target] + return + 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_pos + 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 + self._flush_interval < now: + return self.flush() + + def flush(self): + """ + Forces writing the current play positition information + to disk after checking that there were actual changes. + """ + with self._lock: + self._last_flush_timestamp = time.time() + new_json = json.dumps( + { + 'positions_by_play_target': self._play_targets, + }, indent=2, sort_keys=True) + if self._last_json == new_json: + return + with open(self._tmp_path, 'w') as f: + f.write(new_json) + os.rename(self._tmp_path, self._path) + self._last_json = new_json + logger.debug(f'Flushed state to {self._path}') + + def __del__(self): + self.flush()