Skip to content

Commit dbfe64f

Browse files
committed
Add resume position tracking for all play types
The tracking is active by default, but the resuming has to be enabled explicitly, either by setting playermpd.play_position_tracking.resume_by_default: true or by calling the play_* functions with the new resume=True kwarg. Related: MiczFlor#1946
1 parent 3865c5a commit dbfe64f

File tree

4 files changed

+187
-5
lines changed

4 files changed

+187
-5
lines changed

documentation/developers/status.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,8 @@ Topics marked _in progress_ are already in the process of implementation by comm
106106

107107
- [ ] Folder configuration (_in progress_)
108108
- [ ] [Reference](https://github.com/MiczFlor/RPi-Jukebox-RFID/wiki/MANUAL#manage-playout-behaviour)
109-
- [ ] Resume: Save and restore position (how interact with shuffle?)
109+
- [x] Resume: Save and restore position
110+
- [ ] Resume during shuffle: How to interact?
110111
- [ ] Repeat Playlist
111112
- [ ] Repeat Song
112113
- [ ] Shuffle

resources/default-settings/jukebox.default.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,10 @@ playermpd:
8787
update_on_startup: true
8888
check_user_rights: true
8989
mpd_conf: ~/.config/mpd/mpd.conf
90+
resume:
91+
resume_by_default: false
92+
file: ../../shared/logs/resume_positions.json
93+
flush_interval_seconds: 30
9094
rpc:
9195
tcp_port: 5555
9296
websocket_port: 5556

src/jukebox/components/playermpd/__init__.py

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@
8787
import logging
8888
import time
8989
import functools
90+
from typing import Optional
9091
from pathlib import Path
9192
import components.player
9293
import jukebox.cfghandler
@@ -100,6 +101,7 @@
100101
from jukebox.NvManager import nv_manager
101102
from .playcontentcallback import PlayContentCallbacks, PlayCardState
102103
from .coverart_cache_manager import CoverartCacheManager
104+
from .resume_position_tracker import ResumePositionTracker
103105

104106
logger = logging.getLogger('jb.PlayerMPD')
105107
cfg = jukebox.cfghandler.get_handler('jukebox')
@@ -193,6 +195,8 @@ def __init__(self):
193195
# Change this to last_played_folder and shutdown_state (for restoring)
194196
self.music_player_status['player_status']['last_played_folder'] = ''
195197

198+
self.resume_position_tracker = ResumePositionTracker()
199+
196200
self.old_song = None
197201
self.mpd_status = {}
198202
self.mpd_status_poll_interval = 0.25
@@ -270,6 +274,7 @@ def _mpd_status_poll(self):
270274
self.current_folder_status["LOOP"] = "OFF"
271275
self.current_folder_status["SINGLE"] = "OFF"
272276

277+
self.resume_position_tracker.handle_mpd_status(self.mpd_status)
273278
# Delete the volume key to avoid confusion
274279
# Volume is published via the 'volume' component!
275280
try:
@@ -308,11 +313,13 @@ def update_wait(self):
308313
def play(self):
309314
with self.mpd_lock:
310315
self.mpd_client.play()
316+
self.resume_position_tracker.flush()
311317

312318
@plugs.tag
313319
def stop(self):
314320
with self.mpd_lock:
315321
self.mpd_client.stop()
322+
self.resume_position_tracker.flush()
316323

317324
@plugs.tag
318325
def pause(self, state: int = 1):
@@ -323,24 +330,28 @@ def pause(self, state: int = 1):
323330
"""
324331
with self.mpd_lock:
325332
self.mpd_client.pause(state)
333+
self.resume_position_tracker.flush()
326334

327335
@plugs.tag
328336
def prev(self):
329337
logger.debug("Prev")
330338
with self.mpd_lock:
331339
self.mpd_client.previous()
340+
self.resume_position_tracker.flush()
332341

333342
@plugs.tag
334343
def next(self):
335344
"""Play next track in current playlist"""
336345
logger.debug("Next")
337346
with self.mpd_lock:
338347
self.mpd_client.next()
348+
self.resume_position_tracker.flush()
339349

340350
@plugs.tag
341351
def seek(self, new_time):
342352
with self.mpd_lock:
343353
self.mpd_client.seekcur(new_time)
354+
self.resume_position_tracker.flush()
344355

345356
@plugs.tag
346357
def rewind(self):
@@ -351,6 +362,7 @@ def rewind(self):
351362
logger.debug("Rewind")
352363
with self.mpd_lock:
353364
self.mpd_client.play(0)
365+
self.resume_position_tracker.flush()
354366

355367
@plugs.tag
356368
def replay(self):
@@ -367,6 +379,7 @@ def toggle(self):
367379
"""Toggle pause state, i.e. do a pause / resume depending on current state"""
368380
with self.mpd_lock:
369381
self.mpd_client.pause()
382+
self.resume_position_tracker.flush()
370383

371384
@plugs.tag
372385
def replay_if_stopped(self):
@@ -465,12 +478,35 @@ def move(self):
465478
raise NotImplementedError
466479

467480
@plugs.tag
468-
def play_single(self, song_url):
481+
def play_single(self, song_url, resume=None):
482+
play_target = ('single', song_url)
469483
with self.mpd_lock:
484+
if self._play_or_pause_current(play_target):
485+
return
470486
self.mpd_client.clear()
471487
self.mpd_client.addid(song_url)
488+
self._mpd_resume_from_saved_position(play_target, resume)
472489
self.mpd_client.play()
473490

491+
def _play_or_pause_current(self, play_target):
492+
if self.resume_position_tracker.is_current_play_target(play_target):
493+
if self.mpd_status['state'] == 'play':
494+
# Do nothing
495+
return True
496+
if self.mpd_status['state'] == 'pause':
497+
logger.debug('Unpausing as the play target is identical')
498+
self.mpd_client.play()
499+
return True
500+
return False
501+
502+
def _mpd_resume_from_saved_position(self, play_target, resume: Optional[bool]):
503+
playlist_position = self.resume_position_tracker.get_playlist_position_by_play_target(play_target) or 0
504+
seek_position = self.resume_position_tracker.get_seek_position_by_play_target(play_target) or 0
505+
self.resume_position_tracker.set_current_play_target(play_target)
506+
if resume or (resume is None and self.resume_position_tracker.resume_by_default):
507+
logger.debug(f'Restoring saved position for {play_target}')
508+
self.mpd_client.seek(playlist_position, seek_position)
509+
474510
@plugs.tag
475511
def resume(self):
476512
with self.mpd_lock:
@@ -482,11 +518,14 @@ def resume(self):
482518
@plugs.tag
483519
def play_card(self, folder: str, recursive: bool = False):
484520
"""
485-
Main entry point for trigger music playing from RFID reader. Decodes second swipe options before playing folder content
521+
Deprecated (?) main entry point for trigger music playing from RFID reader.
522+
Decodes second swipe options before playing folder content
486523
487524
Checks for second (or multiple) trigger of the same folder and calls first swipe / second swipe action
488525
accordingly.
489526
527+
Note: The Web UI currently uses play_single/album/folder directly.
528+
490529
:param folder: Folder path relative to music library path
491530
:param recursive: Add folder recursively
492531
"""
@@ -554,7 +593,7 @@ def get_folder_content(self, folder: str):
554593
return plc.playlist
555594

556595
@plugs.tag
557-
def play_folder(self, folder: str, recursive: bool = False) -> None:
596+
def play_folder(self, folder: str, recursive: bool = False, resume: Optional[bool] = None) -> None:
558597
"""
559598
Playback a music folder.
560599
@@ -565,8 +604,11 @@ def play_folder(self, folder: str, recursive: bool = False) -> None:
565604
:param recursive: Add folder recursively
566605
"""
567606
# TODO: This changes the current state -> Need to save last state
607+
play_target = ('folder', folder, recursive)
568608
with self.mpd_lock:
569609
logger.info(f"Play folder: '{folder}'")
610+
if self._play_or_pause_current(play_target):
611+
return
570612
self.mpd_client.clear()
571613

572614
plc = playlistgenerator.PlaylistCollector(components.player.get_music_library_path())
@@ -586,10 +628,11 @@ def play_folder(self, folder: str, recursive: bool = False) -> None:
586628
if self.current_folder_status is None:
587629
self.current_folder_status = self.music_player_status['audio_folder_status'][folder] = {}
588630

631+
self._mpd_resume_from_saved_position(play_target, resume)
589632
self.mpd_client.play()
590633

591634
@plugs.tag
592-
def play_album(self, albumartist: str, album: str):
635+
def play_album(self, albumartist: str, album: str, resume: Optional[bool] = None):
593636
"""
594637
Playback a album found in MPD database.
595638
@@ -599,10 +642,14 @@ def play_album(self, albumartist: str, album: str):
599642
:param albumartist: Artist of the Album provided by MPD database
600643
:param album: Album name provided by MPD database
601644
"""
645+
play_target = ('album', albumartist, album)
602646
with self.mpd_lock:
603647
logger.info(f"Play album: '{album}' by '{albumartist}")
648+
if self._play_or_pause_current(play_target):
649+
return
604650
self.mpd_client.clear()
605651
self.mpd_retry_with_mutex(self.mpd_client.findadd, 'albumartist', albumartist, 'album', album)
652+
self._mpd_resume_from_saved_position(play_target, resume)
606653
self.mpd_client.play()
607654

608655
@plugs.tag
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import time
2+
import os
3+
import logging
4+
import threading
5+
import json
6+
import jukebox.cfghandler
7+
8+
9+
NO_SEEK_IF_NEAR_START_END_CUTOFF = 5
10+
11+
logger = logging.getLogger('jb.PlayerMPD.ResumePositionTracker')
12+
cfg = jukebox.cfghandler.get_handler('jukebox')
13+
14+
15+
def play_target_to_key(play_target) -> str:
16+
"""
17+
Play targets encode how the current playlist was constructed.
18+
play_target_to_key converts this information into a json-serializable string
19+
"""
20+
return '|'.join([str(x) for x in play_target])
21+
22+
23+
class ResumePositionTracker:
24+
"""
25+
Keeps track of playlist and in-song position for played single tracks,
26+
albums or folders.
27+
Syncs to disk every at the configured interval and on all relevant user input
28+
(e.g. card swipes, prev, next, ...).
29+
Provides methods to retrieve the stored values to resume playing.
30+
"""
31+
32+
_last_flush_timestamp: float = 0
33+
_last_json: str = ''
34+
35+
def __init__(self):
36+
self._path = cfg.getn('playermpd', 'resume', 'file',
37+
default='../../shared/logs/resume_positions.json')
38+
self._flush_interval = cfg.getn('playermpd', 'resume', 'flush_interval_seconds',
39+
default=30)
40+
self.resume_by_default = cfg.getn('playermpd', 'resume', 'resume_by_default',
41+
default=False)
42+
self._lock = threading.RLock()
43+
self._tmp_path = self._path + '.tmp'
44+
self._current_play_target = None
45+
with self._lock:
46+
self._load()
47+
48+
def _load(self):
49+
logger.debug(f'Loading from {self._path}')
50+
try:
51+
with open(self._path) as f:
52+
d = json.load(f)
53+
except FileNotFoundError:
54+
logger.debug('File not found, assuming empty list')
55+
self._play_targets = {}
56+
self.flush()
57+
return
58+
self._play_targets = d['positions_by_play_target']
59+
logger.debug(f'Loaded {len(self._play_targets.keys())} saved target play positions')
60+
61+
def set_current_play_target(self, play_target):
62+
with self._lock:
63+
self._current_play_target = play_target_to_key(play_target)
64+
65+
def is_current_play_target(self, play_target):
66+
return self._current_play_target == play_target
67+
68+
def get_playlist_position_by_play_target(self, play_target):
69+
return self._play_targets.get(play_target_to_key(play_target), {}).get('playlist_position')
70+
71+
def get_seek_position_by_play_target(self, play_target):
72+
return self._play_targets.get(play_target_to_key(play_target), {}).get('seek_position')
73+
74+
def handle_mpd_status(self, status):
75+
if not self._current_play_target:
76+
return
77+
playlist_len = int(status.get('playlistlength', -1))
78+
playlist_pos = int(status.get('pos', 0))
79+
elapsed = float(status.get('elapsed', 0))
80+
duration = float(status.get('duration', 0))
81+
is_end_of_playlist = playlist_pos == playlist_len - 1
82+
is_end_of_track = duration - elapsed < NO_SEEK_IF_NEAR_START_END_CUTOFF
83+
if status.get('state') == 'stop' and is_end_of_playlist and is_end_of_track:
84+
# If we are at the end of the playlist,
85+
# we want to restart the playlist the next time the card is present.
86+
# Therefore, delete all resume information:
87+
if self._current_play_target in self._play_targets:
88+
with self._lock:
89+
del self._play_targets[self._current_play_target]
90+
return
91+
with self._lock:
92+
if self._current_play_target not in self._play_targets:
93+
self._play_targets[self._current_play_target] = {}
94+
self._play_targets[self._current_play_target]['playlist_position'] = playlist_pos
95+
if (elapsed < NO_SEEK_IF_NEAR_START_END_CUTOFF
96+
or ((duration - elapsed) < NO_SEEK_IF_NEAR_START_END_CUTOFF)):
97+
# restart song next time:
98+
elapsed = 0
99+
with self._lock:
100+
if self._current_play_target not in self._play_targets:
101+
self._play_targets[self._current_play_target] = {}
102+
self._play_targets[self._current_play_target]['seek_position'] = elapsed
103+
self._flush_if_necessary()
104+
105+
def _flush_if_necessary(self):
106+
now = time.time()
107+
if self._last_flush_timestamp + self._flush_interval < now:
108+
return self.flush()
109+
110+
def flush(self):
111+
"""
112+
Forces writing the current play positition information
113+
to disk after checking that there were actual changes.
114+
"""
115+
with self._lock:
116+
self._last_flush_timestamp = time.time()
117+
new_json = json.dumps(
118+
{
119+
'positions_by_play_target': self._play_targets,
120+
}, indent=2, sort_keys=True)
121+
if self._last_json == new_json:
122+
return
123+
with open(self._tmp_path, 'w') as f:
124+
f.write(new_json)
125+
os.rename(self._tmp_path, self._path)
126+
self._last_json = new_json
127+
logger.debug(f'Flushed state to {self._path}')
128+
129+
def __del__(self):
130+
self.flush()

0 commit comments

Comments
 (0)