From dca9d1b3f635e99e30f67540425b6a502921be57 Mon Sep 17 00:00:00 2001
From: Christian Hoffmann <mail@hoffmann-christian.info>
Date: Sat, 30 Mar 2024 20:10:03 +0100
Subject: [PATCH] Add play position tracking for all play types

Related: #1946
---
 src/jukebox/components/playermpd/__init__.py  |  48 +++++++-
 .../playermpd/play_position_tracker.py        | 115 ++++++++++++++++++
 2 files changed, 162 insertions(+), 1 deletion(-)
 create mode 100644 src/jukebox/components/playermpd/play_position_tracker.py

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..25b8d5685
--- /dev/null
+++ b/src/jukebox/components/playermpd/play_position_tracker.py
@@ -0,0 +1,115 @@
+"""
+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
+    _last_json = None
+
+    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_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 + FLUSH_INTERVAL < now:
+            return self.flush()
+
+    def flush(self):
+        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()