Add support for skip/seek (#355)
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Wed, 1 Jun 2022 19:15:27 +0000 (21:15 +0200)
committerGitHub <noreply@github.com>
Wed, 1 Jun 2022 19:15:27 +0000 (21:15 +0200)
* prepare skip and resume in queue model

* recalculate queue progress with seek

music_assistant/controllers/stream.py
music_assistant/helpers/audio.py
music_assistant/models/media_items.py
music_assistant/models/player_queue.py

index 29c1767a2c63c49df0910eb70c87fb162d912a4b..246dddb59174376b416692ece0bf4074c0e5dbdd 100644 (file)
@@ -434,9 +434,10 @@ class StreamController:
             track_count += 1
             if track_count == 1:
                 # report start of queue playback so we can calculate current track/duration etc.
-                queue_index = await queue.queue_stream_start()
+                queue_index, seek_position = await queue.queue_stream_start()
             else:
                 queue_index = await queue.queue_stream_next(queue_index)
+                seek_position = 0
             queue_track = queue.get_item(queue_index)
             if not queue_track:
                 self.logger.debug(
@@ -505,6 +506,7 @@ class StreamController:
                 queue_track.name,
                 queue.player.name,
             )
+            queue_track.streamdetails.seconds_skipped = seek_position
             fade_in_part = b""
             cur_chunk = 0
             prev_chunk = None
@@ -516,6 +518,7 @@ class StreamController:
                 pcm_fmt,
                 resample=sample_rate,
                 chunk_size=buffer_size,
+                seek_position=seek_position,
             ):
                 cur_chunk += 1
 
@@ -621,9 +624,8 @@ class StreamController:
                 if diff:
                     await asyncio.sleep(diff)
             # end of the track reached
-            # update actual duration to the queue for more accurate now playing info
-            accurate_duration = bytes_written / sample_size
-            queue_track.duration = accurate_duration
+            # set actual duration to the queue for more accurate now playing info
+            queue_track.streamdetails.seconds_played = bytes_written / sample_size
             self.logger.debug(
                 "Finished Streaming queue track: %s (%s) on queue %s",
                 queue_track.uri,
index 9886ff3b21284f9b805f97a3cd420478844348fa..c17cb86ee2464ddbaf9eb0bc4742cb17ee48ec1a 100644 (file)
@@ -329,6 +329,7 @@ async def get_sox_args(
     streamdetails: StreamDetails,
     output_format: Optional[ContentType] = None,
     resample: Optional[int] = None,
+    seek_position: Optional[int] = None,
 ) -> List[str]:
     """Collect all args to send to the sox (or ffmpeg) process."""
     stream_path = streamdetails.path
@@ -338,9 +339,10 @@ async def get_sox_args(
         output_format = input_format
 
     sox_present, ffmpeg_present = await check_audio_support()
+    use_ffmpeg = not sox_present or not input_format.sox_supported() or seek_position
 
     # use ffmpeg if content not supported by SoX (e.g. AAC radio streams)
-    if not sox_present or not input_format.sox_supported():
+    if use_ffmpeg:
         if not ffmpeg_present:
             raise AudioError(
                 "FFmpeg binary is missing from system."
@@ -387,6 +389,8 @@ async def get_sox_args(
             filter_args += ["-filter:a", f"volume={streamdetails.gain_correct}dB"]
         if resample or input_format.is_pcm():
             filter_args += ["-ar", str(resample)]
+        if seek_position:
+            filter_args += ["-ss", str(seek_position)]
         return input_args + filter_args + output_args
 
     # Prefer SoX for all other (=highest quality)
@@ -434,6 +438,7 @@ async def get_media_stream(
     output_format: Optional[ContentType] = None,
     resample: Optional[int] = None,
     chunk_size: Optional[int] = None,
+    seek_position: Optional[int] = None,
 ) -> AsyncGenerator[Tuple[bool, bytes], None]:
     """Get the audio stream for the given streamdetails."""
 
@@ -444,7 +449,7 @@ async def get_media_stream(
             data=streamdetails,
         )
     )
-    args = await get_sox_args(streamdetails, output_format, resample)
+    args = await get_sox_args(streamdetails, output_format, resample, seek_position)
     async with AsyncProcess(args) as sox_proc:
 
         LOGGER.debug(
index 7cbc3fae42f1b01593231a241025b737591608d3..bdc3b5dbba46ea20aaab716fcd5e4f904a3b945e 100755 (executable)
@@ -346,6 +346,7 @@ class StreamDetails(DataClassDictMixin):
     player_id: str = ""
     details: Dict[str, Any] = field(default_factory=dict)
     seconds_played: int = 0
+    seconds_skipped: int = 0
     gain_correct: float = 0
     loudness: Optional[float] = None
     sample_rate: int = 44100
index 9dbc7b1d9b2c2d0ae3cf60ceef9d47e5ba884fcc..064e368a571e422faaf73e16e5f59c9591a1945f 100644 (file)
@@ -40,14 +40,16 @@ class PlayerQueue:
         self._prev_item: Optional[QueueItem] = None
         # start_index: from which index did the queuestream start playing
         self._start_index: int = 0
+        # start_pos: from which position (in seconds) did the first track start playing?
+        self._start_pos: int = 0
         self._next_start_index: int = 0
+        self._next_start_pos: int = 0
         self._last_state = PlayerState.IDLE
         self._items: List[QueueItem] = []
         self._save_task: TimerHandle = None
         self._update_task: Task = None
         self._signal_next: bool = False
         self._last_player_update: int = 0
-
         self._stream_url: str = ""
 
     async def setup(self) -> None:
@@ -174,7 +176,7 @@ class PlayerQueue:
             uris = [uris]
         queue_items = []
         for uri in uris:
-            # parse provided uri into a MA MediaItem or Basis QueueItem from URL
+            # parse provided uri into a MA MediaItem or Basic QueueItem from URL
             try:
                 media_item = await self.mass.music.get_item_by_uri(uri)
             except MusicAssistantError as err:
@@ -221,6 +223,8 @@ class PlayerQueue:
         if self._current_index and self._current_index >= (len(self._items) - 1):
             self._current_index = None
             self._items = []
+        # clear resume point if any
+        self._start_pos = 0
 
         # load items into the queue
         if queue_opt == QueueOption.REPLACE:
@@ -274,18 +278,39 @@ class PlayerQueue:
             return
         await self.play_index(max(self._current_index - 1, 0))
 
+    async def skip_ahead(self, seconds: int = 10) -> None:
+        """Skip X seconds ahead in track."""
+        await self.seek(self.elapsed_time + seconds)
+
+    async def skip_back(self, seconds: int = 10) -> None:
+        """Skip X seconds back in track."""
+        await self.seek(self.elapsed_time - seconds)
+
+    async def seek(self, position: int) -> None:
+        """Seek to a specific position in the track (given in seconds)."""
+        assert self.current_item, "No item loaded"
+        assert position < self.current_item.duration, "Position exceeds track duration"
+        await self.play_index(self._current_index, position)
+
     async def resume(self) -> None:
         """Resume previous queue."""
-        # TODO: Support skipping to last known position
-        if self._items:
-            prev_index = self._current_index
-            await self.play_index(prev_index)
+        resume_item = self.current_item
+        resume_pos = self._current_item_elapsed_time
+        if resume_item and resume_pos > (resume_item.duration * 0.8):
+            # track is already played for > 80% - skip to next
+            resume_item = self.next_item
+            resume_pos = 0
+
+        if resume_item is not None:
+            await self.play_index(resume_item.item_id, resume_pos)
         else:
             self.logger.warning(
                 "resume queue requested for %s but queue is empty", self.queue_id
             )
 
-    async def play_index(self, index: Union[int, str], passive: bool = False) -> None:
+    async def play_index(
+        self, index: Union[int, str], seek_position: int = 0, passive: bool = False
+    ) -> None:
         """Play item at index (or item_id) X in queue."""
         if self.player.use_multi_stream:
             await self.mass.streams.stop_multi_client_queue_stream(self.queue_id)
@@ -297,6 +322,7 @@ class PlayerQueue:
             return
         self._current_index = index
         self._next_start_index = index
+        self._next_start_pos = int(seek_position)
         # send stream url to player connected to this queue
         self._stream_url = self.mass.streams.get_stream_url(
             self.queue_id, content_type=self._settings.stream_type
@@ -440,10 +466,12 @@ class PlayerQueue:
 
             # always signal update if playback state changed
             self.signal_update()
-            cur_index = self._current_index or 0
             if self.player.state == PlayerState.IDLE:
+
                 # handle end of queue
-                if cur_index >= (len(self._items) - 1):
+                if self._current_index is not None and self._current_index >= (
+                    len(self._items) - 1
+                ):
                     self._current_index += 1
                     self._current_item_elapsed_time = 0
                     # repeat enabled (of whole queue), play queue from beginning
@@ -492,9 +520,6 @@ class PlayerQueue:
         ):
             # new active item in queue
             new_item_loaded = True
-            # invalidate previous streamdetails
-            if self._prev_item:
-                self._prev_item.streamdetails = None
             self._prev_item = self.current_item
         # update vars and signal update on eventbus if needed
         prev_item_time = int(self._current_item_elapsed_time)
@@ -525,7 +550,7 @@ class PlayerQueue:
             await self.play_index(self._current_index + 2)
             raise err
 
-    async def queue_stream_start(self) -> int:
+    async def queue_stream_start(self) -> Tuple[int, int]:
         """Call when queue_streamer starts playing the queue stream."""
         start_from_index = self._next_start_index
         self._current_item_elapsed_time = 0
@@ -533,7 +558,9 @@ class PlayerQueue:
         self._start_index = start_from_index
         self._next_start_index = self.get_next_index(start_from_index)
         self._index_in_buffer = start_from_index
-        return start_from_index
+        seek_position = self._next_start_pos
+        self._next_start_pos = 0
+        return (start_from_index, seek_position)
 
     async def queue_stream_next(self, cur_index: int) -> int | None:
         """Call when queue_streamer loads next track in buffer."""
@@ -600,15 +627,25 @@ class PlayerQueue:
             queue_index = self._start_index
             queue_track = None
             while len(self._items) > queue_index:
+                # keep enumerating the queue tracks to find current track
+                # starting from the start index
                 queue_track = self._items[queue_index]
-                if queue_track.duration is None:
-                    # in case of a radio stream
-                    queue_track.duration = 86400
-                if elapsed_time_queue > (queue_track.duration + total_time):
-                    total_time += queue_track.duration
+                if not queue_track.streamdetails:
+                    track_time = elapsed_time_queue - total_time
+                    break
+                track_duration = (
+                    queue_track.streamdetails.seconds_played or queue_track.duration
+                )
+                if elapsed_time_queue > (track_duration + total_time):
+                    # total elapsed time is more than (streamed) track duration
+                    # move index one up
+                    total_time += track_duration
                     queue_index += 1
                 else:
-                    track_time = elapsed_time_queue - total_time
+                    # no more seconds left to divide, this is our track
+                    # account for any seeking by adding the skipped seconds
+                    track_sec_skipped = queue_track.streamdetails.seconds_skipped
+                    track_time = elapsed_time_queue + track_sec_skipped - total_time
                     break
         return queue_index, track_time