Skip to content

Commit

Permalink
feat: Add resume position tracking
Browse files Browse the repository at this point in the history
The tracking is active by default, but the resuming has to be
enabled explicitly, either by setting
  playermpd.resume.resume_by_default: true
or by calling the play_* functions with the new resume=True kwarg.

Related: #1946
  • Loading branch information
hoffie committed Apr 14, 2024
1 parent fa110b4 commit 9c94056
Show file tree
Hide file tree
Showing 4 changed files with 189 additions and 5 deletions.
3 changes: 2 additions & 1 deletion documentation/developers/status.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions resources/default-settings/jukebox.default.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,10 @@ playermpd:
stopped_prev_action: prev
# Must be one of: 'none', 'next', 'rewind':
stopped_next_action: next
resume:
resume_by_default: false
file: ../../shared/logs/resume_positions.json
flush_interval_seconds: 30
rpc:
tcp_port: 5555
websocket_port: 5556
Expand Down
57 changes: 53 additions & 4 deletions src/jukebox/components/playermpd/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@
import logging
import time
import functools
from typing import Optional
from pathlib import Path
import components.player
import jukebox.cfghandler
Expand All @@ -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')
Expand Down Expand Up @@ -215,6 +217,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
Expand Down Expand Up @@ -292,6 +296,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:
Expand Down Expand Up @@ -330,11 +335,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):
Expand All @@ -345,6 +352,7 @@ 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):
Expand All @@ -359,10 +367,12 @@ def prev(self):
# This shouldn't happen in reality, but we still catch
# this error to avoid crashing the player thread:
logger.warning('Failed to go to previous song, ignoring')
self.resume_position_tracker.flush()

def _prev_in_stopped_state(self):
with self.mpd_lock:
self.mpd_client.play(max(0, int(self.mpd_status['pos']) - 1))
self.resume_position_tracker.flush()

@plugs.tag
def next(self):
Expand All @@ -384,18 +394,21 @@ def next(self):
# This shouldn't happen in reality, but we still catch
# this error to avoid crashing the player thread:
logger.warning('Failed to go to next song, ignoring')
self.resume_position_tracker.flush()

def _next_in_stopped_state(self):
pos = int(self.mpd_status['pos']) + 1
if pos > int(self.mpd_status['playlistlength']) - 1:
return self.end_of_playlist_next_action()
with self.mpd_lock:
self.mpd_client.play(pos)
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):
Expand All @@ -406,6 +419,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):
Expand All @@ -422,6 +436,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):
Expand Down Expand Up @@ -520,12 +535,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:
Expand All @@ -537,11 +575,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
"""
Expand Down Expand Up @@ -609,7 +650,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.
Expand All @@ -620,8 +661,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())
Expand All @@ -641,10 +685,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.
Expand All @@ -654,10 +699,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
Expand Down
130 changes: 130 additions & 0 deletions src/jukebox/components/playermpd/resume_position_tracker.py
Original file line number Diff line number Diff line change
@@ -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()

0 comments on commit 9c94056

Please sign in to comment.