Skip to content

Commit dca9d1b

Browse files
committed
Add play position tracking for all play types
Related: MiczFlor#1946
1 parent 5138bb5 commit dca9d1b

File tree

2 files changed

+162
-1
lines changed

2 files changed

+162
-1
lines changed

src/jukebox/components/playermpd/__init__.py

+47-1
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@
100100
from jukebox.NvManager import nv_manager
101101
from .playcontentcallback import PlayContentCallbacks, PlayCardState
102102
from .coverart_cache_manager import CoverartCacheManager
103+
from .play_position_tracker import PlayPositionTracker
103104

104105
logger = logging.getLogger('jb.PlayerMPD')
105106
cfg = jukebox.cfghandler.get_handler('jukebox')
@@ -193,6 +194,9 @@ def __init__(self):
193194
# Change this to last_played_folder and shutdown_state (for restoring)
194195
self.music_player_status['player_status']['last_played_folder'] = ''
195196

197+
play_position_tracker_file = cfg.getn('playermpd', 'play_position_tracker_file', default='../../shared/settings/play_positions.json')
198+
self.play_position_tracker = PlayPositionTracker(path=play_position_tracker_file)
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.play_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.play_position_tracker.flush()
311317

312318
@plugs.tag
313319
def stop(self):
314320
with self.mpd_lock:
315321
self.mpd_client.stop()
322+
self.play_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.play_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.play_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.play_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.play_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(1)
365+
self.play_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.play_position_tracker.flush()
370383

371384
@plugs.tag
372385
def replay_if_stopped(self):
@@ -466,11 +479,33 @@ def move(self):
466479

467480
@plugs.tag
468481
def play_single(self, song_url):
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_restore_saved_position(play_target)
472489
self.mpd_client.play()
473490

491+
def _play_or_pause_current(self, play_target):
492+
if self.play_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_restore_saved_position(self, play_target):
503+
logger.debug(f'Restoring saved position for {play_target}')
504+
playlist_position = self.play_position_tracker.get_playlist_position_by_play_target(play_target) or 0
505+
seek_position = self.play_position_tracker.get_seek_position_by_play_target(play_target) or 0
506+
self.play_position_tracker.set_current_play_target(play_target)
507+
self.mpd_client.seek(playlist_position, seek_position)
508+
474509
@plugs.tag
475510
def resume(self):
476511
with self.mpd_lock:
@@ -482,11 +517,14 @@ def resume(self):
482517
@plugs.tag
483518
def play_card(self, folder: str, recursive: bool = False):
484519
"""
485-
Main entry point for trigger music playing from RFID reader. Decodes second swipe options before playing folder content
520+
Deprecated (?) main entry point for trigger music playing from RFID reader.
521+
Decodes second swipe options before playing folder content
486522
487523
Checks for second (or multiple) trigger of the same folder and calls first swipe / second swipe action
488524
accordingly.
489525
526+
Note: The Web UI currently uses play_single/album/folder directly.
527+
490528
:param folder: Folder path relative to music library path
491529
:param recursive: Add folder recursively
492530
"""
@@ -587,8 +625,11 @@ def play_folder(self, folder: str, recursive: bool = False) -> None:
587625
:param recursive: Add folder recursively
588626
"""
589627
# TODO: This changes the current state -> Need to save last state
628+
play_target = ('folder', folder, recursive)
590629
with self.mpd_lock:
591630
logger.info(f"Play folder: '{folder}'")
631+
if self._play_or_pause_current(play_target):
632+
return
592633
self.mpd_client.clear()
593634

594635
plc = playlistgenerator.PlaylistCollector(components.player.get_music_library_path())
@@ -608,6 +649,7 @@ def play_folder(self, folder: str, recursive: bool = False) -> None:
608649
if self.current_folder_status is None:
609650
self.current_folder_status = self.music_player_status['audio_folder_status'][folder] = {}
610651

652+
self._mpd_restore_saved_position(play_target)
611653
self.mpd_client.play()
612654

613655
@plugs.tag
@@ -621,10 +663,14 @@ def play_album(self, albumartist: str, album: str):
621663
:param albumartist: Artist of the Album provided by MPD database
622664
:param album: Album name provided by MPD database
623665
"""
666+
play_target = ('album', albumartist, album)
624667
with self.mpd_lock:
625668
logger.info(f"Play album: '{album}' by '{albumartist}")
669+
if self._play_or_pause_current(play_target):
670+
return
626671
self.mpd_client.clear()
627672
self.mpd_retry_with_mutex(self.mpd_client.findadd, 'albumartist', albumartist, 'album', album)
673+
self._mpd_restore_saved_position(play_target)
628674
self.mpd_client.play()
629675

630676
@plugs.tag
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
"""
2+
Keeps track of playlist and in-song position for played single tracks,
3+
albums or folders.
4+
Syncs to disk every FLUSH_INTERVAL seconds.
5+
Provides methods to retrieve the stored values to resume playing.
6+
"""
7+
import time
8+
import os
9+
import logging
10+
import threading
11+
import json
12+
13+
14+
NO_SEEK_IF_NEAR_START_END_CUTOFF = 5
15+
FLUSH_INTERVAL = 30
16+
17+
logger = logging.getLogger('jb.PlayerMPD.PlayPositionTracker')
18+
19+
20+
def play_target_to_key(play_target):
21+
return '|'.join([str(x) for x in play_target])
22+
23+
24+
class PlayPositionTracker:
25+
flush_interval = 30
26+
_last_flush_timestamp = 0
27+
_last_json = None
28+
29+
def __init__(self, path):
30+
self._lock = threading.RLock()
31+
self._path = path
32+
self._tmp_path = path + '.tmp'
33+
self._current_play_target = None
34+
with self._lock:
35+
self._load()
36+
37+
def _load(self):
38+
logger.debug(f'Loading from {self._path}')
39+
try:
40+
with open(self._path) as f:
41+
d = json.load(f)
42+
except FileNotFoundError:
43+
logger.debug('File not found, assuming empty list')
44+
self._play_targets = {}
45+
self.flush()
46+
return
47+
self._play_targets = d['positions_by_play_target']
48+
logger.debug(f'Loaded {len(self._play_targets.keys())} saved target play positions')
49+
50+
def set_current_play_target(self, play_target):
51+
with self._lock:
52+
self._current_play_target = play_target_to_key(play_target)
53+
54+
def is_current_play_target(self, play_target):
55+
return self._current_play_target == play_target
56+
57+
def get_playlist_position_by_play_target(self, play_target):
58+
return self._play_targets.get(play_target_to_key(play_target), {}).get('playlist_position')
59+
60+
def get_seek_position_by_play_target(self, play_target):
61+
return self._play_targets.get(play_target_to_key(play_target), {}).get('seek_position')
62+
63+
def handle_mpd_status(self, status):
64+
if not self._current_play_target:
65+
return
66+
playlist_len = int(status.get('playlistlength', -1))
67+
playlist_pos = int(status.get('pos', 0))
68+
elapsed = float(status.get('elapsed', 0))
69+
duration = float(status.get('duration', 0))
70+
is_end_of_playlist = playlist_pos == playlist_len - 1
71+
is_end_of_track = duration - elapsed < NO_SEEK_IF_NEAR_START_END_CUTOFF
72+
if status.get('state') == 'stop' and is_end_of_playlist and is_end_of_track:
73+
# If we are at the end of the playlist,
74+
# we want to restart the playlist the next time the card is present.
75+
# Therefore, delete all resume information:
76+
if self._current_play_target in self._play_targets:
77+
with self._lock:
78+
del self._play_targets[self._current_play_target]
79+
return
80+
with self._lock:
81+
if self._current_play_target not in self._play_targets:
82+
self._play_targets[self._current_play_target] = {}
83+
self._play_targets[self._current_play_target]['playlist_position'] = playlist_pos
84+
if (elapsed < NO_SEEK_IF_NEAR_START_END_CUTOFF or
85+
((duration - elapsed) < NO_SEEK_IF_NEAR_START_END_CUTOFF)):
86+
# restart song next time:
87+
elapsed = 0
88+
with self._lock:
89+
if self._current_play_target not in self._play_targets:
90+
self._play_targets[self._current_play_target] = {}
91+
self._play_targets[self._current_play_target]['seek_position'] = elapsed
92+
self._flush_if_necessary()
93+
94+
def _flush_if_necessary(self):
95+
now = time.time()
96+
if self._last_flush_timestamp + FLUSH_INTERVAL < now:
97+
return self.flush()
98+
99+
def flush(self):
100+
with self._lock:
101+
self._last_flush_timestamp = time.time()
102+
new_json = json.dumps(
103+
{
104+
'positions_by_play_target': self._play_targets,
105+
}, indent=2, sort_keys=True)
106+
if self._last_json == new_json:
107+
return
108+
with open(self._tmp_path, 'w') as f:
109+
f.write(new_json)
110+
os.rename(self._tmp_path, self._path)
111+
self._last_json = new_json
112+
logger.debug(f'Flushed state to {self._path}')
113+
114+
def __del__(self):
115+
self.flush()

0 commit comments

Comments
 (0)