Various minor bugfixes and enhancements (#2120)
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Tue, 8 Apr 2025 23:26:03 +0000 (01:26 +0200)
committerGitHub <noreply@github.com>
Tue, 8 Apr 2025 23:26:03 +0000 (01:26 +0200)
* Fix invalid loudness measurements in volume normalization

* Fix sort order of podcast feed

* Prefer cache for podcast episodes

* Fix fade-in effect only when resuming from idle

* Chore: fix comments

music_assistant/controllers/music.py
music_assistant/controllers/player_queues.py
music_assistant/controllers/players.py
music_assistant/controllers/streams.py
music_assistant/helpers/audio.py
music_assistant/providers/airplay/raop.py
music_assistant/providers/player_group/__init__.py
music_assistant/providers/podcastfeed/__init__.py

index 223c924ebc035843eb81270a507b94c36619a341..7104643df4c9abef8b254ec351bc8c4e6868f845 100644 (file)
@@ -811,13 +811,16 @@ class MusicController(CoreController):
         """Store (EBU-R128) Integrated Loudness Measurement for a mediaitem in db."""
         if not (provider := self.mass.get_provider(provider_instance_id_or_domain)):
             return
+        if loudness in (None, inf, -inf):
+            # skip invalid values
+            return
         values = {
             "item_id": item_id,
             "media_type": media_type.value,
             "provider": provider.lookup_key,
             "loudness": loudness,
         }
-        if album_loudness is not None:
+        if album_loudness not in (None, inf, -inf):
             values["loudness_album"] = album_loudness
         await self.database.insert_or_replace(DB_TABLE_LOUDNESS_MEASUREMENTS, values)
 
@@ -826,7 +829,7 @@ class MusicController(CoreController):
         item_id: str,
         provider_instance_id_or_domain: str,
         media_type: MediaType = MediaType.TRACK,
-    ) -> tuple[float, float] | None:
+    ) -> tuple[float, float | None] | None:
         """Get (EBU-R128) Integrated Loudness Measurement for a mediaitem in db."""
         if not (provider := self.mass.get_provider(provider_instance_id_or_domain)):
             return None
@@ -839,7 +842,11 @@ class MusicController(CoreController):
             },
         )
         if db_row and db_row["loudness"] != inf and db_row["loudness"] != -inf:
-            return (db_row["loudness"], db_row["loudness_album"])
+            loudness = db_row["loudness"]
+            loudness_album = db_row["loudness_album"]
+            if loudness_album in (inf, -inf):
+                loudness_album = None
+            return (loudness, loudness_album)
 
         return None
 
index 897812cb29260600ce6106a904ffb1078adbbc46..b6ffc4fab7acb2deeb6e39b282513882a6d6673b 100644 (file)
@@ -26,6 +26,7 @@ from music_assistant_models.enums import (
     ContentType,
     EventType,
     MediaType,
+    PlayerFeature,
     PlayerState,
     ProviderFeature,
     QueueOption,
@@ -638,7 +639,36 @@ class PlayerQueuesController(CoreController):
             if queue.state == PlayerState.PLAYING:
                 queue.resume_pos = queue.corrected_elapsed_time
         # forward the actual command to the player controller
-        await self.mass.players.cmd_pause(queue_id)
+        queue_player = self.mass.players.get(queue_id)
+        if not (player_provider := self.mass.players.get_player_provider(queue.queue_id)):
+            return  # guard
+
+        if PlayerFeature.PAUSE not in queue_player.supported_features:
+            # if player does not support pause, we need to send stop
+            await player_provider.cmd_stop(queue_player.player_id)
+            return
+        await player_provider.cmd_pause(queue_player.player_id)
+
+        async def _watch_pause() -> None:
+            count = 0
+            # wait for pause
+            while count < 5 and queue_player.state == PlayerState.PLAYING:
+                count += 1
+                await asyncio.sleep(1)
+            # wait for unpause
+            if queue_player.state != PlayerState.PAUSED:
+                return
+            count = 0
+            while count < 30 and queue_player.state == PlayerState.PAUSED:
+                count += 1
+                await asyncio.sleep(1)
+            # if player is still paused when the limit is reached, send stop
+            if queue_player.state == PlayerState.PAUSED:
+                await player_provider.cmd_stop(queue_player.player_id)
+
+        # we auto stop a player from paused when its paused for 30 seconds
+        if not queue_player.announcement_in_progress:
+            self.mass.create_task(_watch_pause())
 
     @api_command("player_queues/play_pause")
     async def play_pause(self, queue_id: str) -> None:
@@ -733,6 +763,7 @@ class PlayerQueuesController(CoreController):
             # resume requested while already playing,
             # use current position as resume position
             resume_pos = queue.corrected_elapsed_time
+            fade_in = False
         else:
             resume_pos = queue.resume_pos or queue.elapsed_time
 
@@ -745,9 +776,13 @@ class PlayerQueuesController(CoreController):
             resume_pos = 0
 
         if resume_item is not None:
-            resume_pos = resume_pos if resume_pos > 10 else 0
             queue_player = self.mass.players.get(queue_id)
-            if fade_in is None and queue_player.state == PlayerState.IDLE:
+            if (
+                fade_in is None
+                and queue_player.state == PlayerState.IDLE
+                and (time.time() - queue.elapsed_time_last_updated) > 60
+            ):
+                # enable fade in effect if the player is idle for a while
                 fade_in = resume_pos > 0
             if resume_item.media_type == MediaType.RADIO:
                 # we're not able to skip in online radio so this is pointless
index 2d6885b2f5d228d86801edb142de96cce08836d3..48efe1835af03e49fc8386cf9ed0f796416245f0 100644 (file)
@@ -243,9 +243,13 @@ class PlayerController(CoreController):
         - player_id: player_id of the player to handle the command.
         """
         player = self._get_player_with_redirect(player_id)
+        # Redirect to queue controller if it is active
+        if active_queue := self.mass.player_queues.get(player.active_source):
+            await self.mass.player_queues.pause(active_queue.queue_id)
+            return
         if PlayerFeature.PAUSE not in player.supported_features:
             # if player does not support pause, we need to send stop
-            self.logger.info(
+            self.logger.debug(
                 "Player %s does not support pause, using STOP instead",
                 player.display_name,
             )
@@ -254,28 +258,6 @@ class PlayerController(CoreController):
         player_provider = self.get_player_provider(player.player_id)
         await player_provider.cmd_pause(player.player_id)
 
-        async def _watch_pause(_player_id: str) -> None:
-            player = self.get(_player_id, True)
-            count = 0
-            # wait for pause
-            while count < 5 and player.state == PlayerState.PLAYING:
-                count += 1
-                await asyncio.sleep(1)
-            # wait for unpause
-            if player.state != PlayerState.PAUSED:
-                return
-            count = 0
-            while count < 30 and player.state == PlayerState.PAUSED:
-                count += 1
-                await asyncio.sleep(1)
-            # if player is still paused when the limit is reached, send stop
-            if player.state == PlayerState.PAUSED:
-                await self.cmd_stop(_player_id)
-
-        # we auto stop a player from paused when its paused for 30 seconds
-        if not player.announcement_in_progress:
-            self.mass.create_task(_watch_pause(player_id))
-
     @api_command("players/cmd/play_pause")
     async def cmd_play_pause(self, player_id: str) -> None:
         """Toggle play/pause on given player.
@@ -1355,7 +1337,7 @@ class PlayerController(CoreController):
         elif not player_disabled and resume_queue and resume_queue.state == PlayerState.PLAYING:
             # always stop first to ensure the player uses the new config
             await self.mass.player_queues.stop(resume_queue.queue_id)
-            self.mass.call_later(1, self.mass.player_queues.resume, resume_queue.queue_id)
+            self.mass.call_later(1, self.mass.player_queues.resume, resume_queue.queue_id, False)
         # check for group memberships that need to be updated
         if player_disabled and player.active_group and player_provider:
             # try to remove from the group
@@ -1375,7 +1357,7 @@ class PlayerController(CoreController):
         if player.state == PlayerState.PLAYING:
             self.logger.info("Restarting playback of Player %s after DSP change", player_id)
             # this will restart ffmpeg with the new settings
-            self.mass.call_later(0, self.mass.player_queues.resume, player.active_source)
+            self.mass.call_later(0, self.mass.player_queues.resume, player.active_source, False)
 
     def _get_player_with_redirect(self, player_id: str) -> Player:
         """Get player with check if playback related command should be redirected."""
index 6733850e633668fc58f2d151906e2e197ddfa52c..dd944d15c384ec4e6e141eb150926398e6385f2c 100644 (file)
@@ -14,7 +14,6 @@ import shutil
 import urllib.parse
 from collections.abc import AsyncGenerator
 from dataclasses import dataclass, field
-from math import inf
 from typing import TYPE_CHECKING
 
 from aiofiles.os import wrap
@@ -1022,10 +1021,7 @@ class StreamsController(CoreController):
         elif streamdetails.volume_normalization_mode == VolumeNormalizationMode.MEASUREMENT_ONLY:
             # volume normalization with known loudness measurement
             # apply volume/gain correction
-            if streamdetails.prefer_album_loudness and streamdetails.loudness_album not in (
-                inf,
-                -inf,
-            ):
+            if streamdetails.prefer_album_loudness and streamdetails.loudness_album is not None:
                 gain_correct = streamdetails.target_loudness - streamdetails.loudness_album
             else:
                 gain_correct = streamdetails.target_loudness - streamdetails.loudness
index 495e4f9611b0f5b0d542695a3b49057b38929e4e..85620486521030e8f859b056abc2bef4e1a9c4d1 100644 (file)
@@ -676,15 +676,20 @@ async def _is_cache_allowed(mass: MusicAssistant, streamdetails: StreamDetails)
     elif streamdetails.stream_type == StreamType.CUSTOM:
         # prefer cache for custom streams (to speedup seeking)
         max_filesize = 250 * 1024 * 1024  # 250MB
-        return get_chunksize(streamdetails.audio_format, streamdetails.duration) < max_filesize
     elif streamdetails.stream_type == StreamType.HLS:
         # prefer cache for HLS streams (to speedup seeking)
         max_filesize = 250 * 1024 * 1024  # 250MB
+    elif streamdetails.media_type in (
+        MediaType.AUDIOBOOK,
+        MediaType.PODCAST_EPISODE,
+    ):
+        # prefer cache for audiobooks and episodes (to speedup seeking)
+        max_filesize = 2 * 1024 * 1024 * 1024  # 2GB
     elif streamdetails.provider in SLOW_PROVIDERS:
         # prefer cache for slow providers
-        max_filesize = 500 * 1024 * 1024  # 500MB
+        max_filesize = 2 * 1024 * 1024 * 1024  # 2GB
     else:
-        max_filesize = 25 * 1024 * 1024
+        max_filesize = 50 * 1024 * 1024
 
     return estimated_filesize < max_filesize
 
index a83958594441d91a4b85d2e99f674052afaf9666..93151f2ba85adc72ea13e4262912f44fc2a34b1f 100644 (file)
@@ -449,7 +449,7 @@ class RaopStream:
                 lost_packets += 1
                 if lost_packets == 100:
                     logger.error("High packet loss detected, restarting playback...")
-                    self.mass.create_task(self.mass.player_queues.resume(queue.queue_id))
+                    self.mass.create_task(self.mass.player_queues.resume(queue.queue_id, False))
                 else:
                     logger.warning("Packet loss detected!")
             if "end of stream reached" in line:
index 637a20f586a1b4eda8dd81eb0a925a20e83f64bc..b354dc758173150e965ab4bef8166fe640f68510 100644 (file)
@@ -664,7 +664,7 @@ class PlayerGroupProvider(PlayerProvider):
         player_provider = self.mass.players.get_player_provider(child_player.player_id)
         if group_type == GROUP_TYPE_UNIVERSAL:
             if was_playing:
-                # stop playing the group player
+                # stop playing the child player that was unjoined from the UGP
                 await player_provider.cmd_stop(child_player.player_id)
             self._update_attributes(group_player)
             return
@@ -672,7 +672,7 @@ class PlayerGroupProvider(PlayerProvider):
         if child_player.group_childs:
             # this is the sync leader, unsync all its childs!
             # NOTE that some players/providers might support this in a less intrusive way
-            # but for now we just ungroup all childs to keep thinngs universal
+            # but for now we just ungroup all childs to keep things universal
             self.logger.info("Detected ungroup of sync leader, ungrouping all childs")
             async with TaskManager(self.mass) as tg:
                 for sync_child_id in child_player.group_childs:
@@ -915,7 +915,7 @@ class PlayerGroupProvider(PlayerProvider):
                 changed = True
         if changed and player.state == PlayerState.PLAYING:
             # Restart playback to ensure all members play the same content
-            await self.mass.player_queues.resume(player.player_id)
+            await self.mass.player_queues.resume(player.player_id, False)
 
     async def _serve_ugp_stream(self, request: web.Request) -> web.Response:
         """Serve the UGP (multi-client) flow stream audio to a player."""
index 7d4a1ee6b8a4efa949439312753a26fe987f7baa..c63487fef2cccb4752b0be6db831685d67c26134 100644 (file)
@@ -23,18 +23,11 @@ from music_assistant_models.enums import (
     StreamType,
 )
 from music_assistant_models.errors import InvalidProviderURI, MediaNotFoundError
-from music_assistant_models.media_items import (
-    AudioFormat,
-    Podcast,
-    PodcastEpisode,
-)
+from music_assistant_models.media_items import AudioFormat, Podcast, PodcastEpisode
 from music_assistant_models.streamdetails import StreamDetails
 
 from music_assistant.helpers.compare import create_safe_string
-from music_assistant.helpers.podcast_parsers import (
-    parse_podcast,
-    parse_podcast_episode,
-)
+from music_assistant.helpers.podcast_parsers import parse_podcast, parse_podcast_episode
 from music_assistant.models.music_provider import MusicProvider
 
 if TYPE_CHECKING:
@@ -158,7 +151,11 @@ class PodcastMusicprovider(MusicProvider):
         """List all episodes for the podcast."""
         if prov_podcast_id != self.podcast_id:
             raise Exception(f"Podcast id not in provider: {prov_podcast_id}")
-        for idx, episode in enumerate(self.parsed_podcast["episodes"]):
+        # sort episodes by published date
+        episodes: list[dict[str, Any]] = self.parsed_podcast["episodes"]
+        if episodes and episodes[0].get("published", 0) != 0:
+            episodes.sort(key=lambda x: x.get("published", 0))
+        for idx, episode in enumerate(episodes):
             yield await self._parse_episode(episode, idx)
 
     async def get_stream_details(self, item_id: str, media_type: MediaType) -> StreamDetails: