Add Version suffix config to scrobble providers (#2709)
authorWillem-Jan Zijderveld <contact+github@veld.live>
Fri, 28 Nov 2025 22:46:59 +0000 (23:46 +0100)
committerGitHub <noreply@github.com>
Fri, 28 Nov 2025 22:46:59 +0000 (23:46 +0100)
music_assistant/helpers/scrobbler.py
music_assistant/providers/lastfm_scrobble/__init__.py
music_assistant/providers/listenbrainz_scrobble/__init__.py
tests/core/test_scrobbler.py

index 197c7646e41bbe8e28288362122c38693de4a3d9..81af9a34276dde7482c3d6fbef4875657921d6eb 100644 (file)
@@ -5,6 +5,13 @@ from __future__ import annotations
 import logging
 from typing import TYPE_CHECKING
 
+from music_assistant_models.config_entries import (
+    Config,
+    ConfigEntry,
+    ConfigValueType,
+)
+from music_assistant_models.enums import ConfigEntryType
+
 if TYPE_CHECKING:
     from music_assistant_models.event import MassEvent
     from music_assistant_models.playback_progress_report import MediaItemPlaybackProgressReport
@@ -14,17 +21,26 @@ class ScrobblerHelper:
     """Base class to aid scrobbling tracks."""
 
     logger: logging.Logger
+    config: ScrobblerConfig
     currently_playing: str | None = None
     last_scrobbled: str | None = None
 
-    def __init__(self, logger: logging.Logger) -> None:
+    def __init__(self, logger: logging.Logger, config: ScrobblerConfig | None = None) -> None:
         """Initialize."""
         self.logger = logger
+        self.config = config or ScrobblerConfig(suffix_version=False)
 
     def _is_configured(self) -> bool:
         """Override if subclass needs specific configuration."""
         return True
 
+    def get_name(self, report: MediaItemPlaybackProgressReport) -> str:
+        """Get the track name to use for scrobbling, possibly appended with version info."""
+        if self.config.suffix_version and report.version:
+            return f"{report.name} ({report.version})"
+
+        return report.name
+
     async def _update_now_playing(self, report: MediaItemPlaybackProgressReport) -> None:
         """Send a Now Playing update to the scrobbling service."""
 
@@ -85,3 +101,35 @@ class ScrobblerHelper:
         # the exact context in which the event was fired
         # we can only rely on fully_played for now
         return bool(report.fully_played)
+
+
+CONF_VERSION_SUFFIX = "suffix_version"
+
+
+class ScrobblerConfig:
+    """Shared configuration options for scrobblers."""
+
+    def __init__(self, suffix_version: bool) -> None:
+        """Initialize."""
+        self.suffix_version = suffix_version
+
+    @staticmethod
+    def get_shared_config_entries(values: dict[str, ConfigValueType] | None) -> list[ConfigEntry]:
+        """Shared config entries."""
+        return [
+            ConfigEntry(
+                key=CONF_VERSION_SUFFIX,
+                type=ConfigEntryType.BOOLEAN,
+                label="Suffix version to track names",
+                required=True,
+                description="Whether to add the version as suffix to track names,"
+                "e.g. 'Amazing Track (Live)'.",
+                default_value=True,
+                value=values.get(CONF_VERSION_SUFFIX) if values else None,
+            )
+        ]
+
+    @staticmethod
+    def create_from_config(config: Config) -> ScrobblerConfig:
+        """Extract relevant shared config values."""
+        return ScrobblerConfig(bool(config.get_value(CONF_VERSION_SUFFIX, True)))
index 1f4dc5a16867266ade7280bc43aa68c5b0d12a7f..306e08530fc5c9112bfc1e51fa8b44210c467a7d 100644 (file)
@@ -21,7 +21,7 @@ from music_assistant_models.provider import ProviderManifest
 
 from music_assistant.constants import MASS_LOGGER_NAME
 from music_assistant.helpers.auth import AuthenticationHelper
-from music_assistant.helpers.scrobbler import ScrobblerHelper
+from music_assistant.helpers.scrobbler import ScrobblerConfig, ScrobblerHelper
 from music_assistant.mass import MusicAssistant
 from music_assistant.models import ProviderInstanceType
 from music_assistant.models.plugin import PluginProvider
@@ -72,14 +72,13 @@ class LastFMScrobbleProvider(PluginProvider):
         await super().loaded_in_mass()
 
         # subscribe to media_item_played event
-        handler = LastFMEventHandler(self.network, self.logger)
+        handler = LastFMEventHandler(self.network, self.logger, self.config)
         self._on_unload.append(
             self.mass.subscribe(handler._on_mass_media_item_played, EventType.MEDIA_ITEM_PLAYED)
         )
 
     async def unload(self, is_removed: bool = False) -> None:
-        """
-        Handle unload/close of the provider.
+        """Handle unload/close of the provider.
 
         Called when provider is deregistered (e.g. MA exiting or config reloading).
         """
@@ -101,9 +100,11 @@ class LastFMEventHandler(ScrobblerHelper):
 
     network: pylast._Network
 
-    def __init__(self, network: pylast._Network, logger: logging.Logger) -> None:
+    def __init__(
+        self, network: pylast._Network, logger: logging.Logger, config: ProviderConfig
+    ) -> None:
         """Initialize."""
-        super().__init__(logger)
+        super().__init__(logger, ScrobblerConfig.create_from_config(config))
         self.network = network
 
     async def _update_now_playing(self, report: MediaItemPlaybackProgressReport) -> None:
@@ -112,7 +113,7 @@ class LastFMEventHandler(ScrobblerHelper):
         await asyncio.to_thread(
             self.network.update_now_playing,
             report.artist,
-            report.name,
+            self.get_name(report),
             report.album,
             duration=report.duration,
             mbid=report.mbid,
@@ -126,7 +127,7 @@ class LastFMEventHandler(ScrobblerHelper):
         await asyncio.to_thread(
             self.network.scrobble,
             report.artist or "unknown artist",
-            report.name,
+            self.get_name(report),
             int(time.time()),
             report.album,
             duration=report.duration,
@@ -169,7 +170,8 @@ async def get_config_entries(
         provider = str(values.get(CONF_PROVIDER))
 
     # collect all config entries to show
-    entries: list[ConfigEntry] = [
+    entries: list[ConfigEntry] = ScrobblerConfig.get_shared_config_entries(values)
+    entries += [
         ConfigEntry(
             key=CONF_PROVIDER,
             type=ConfigEntryType.STRING,
index 8f4e3c52d11fc762b6894ae31bddc857315548c8..33dfe99fb18845c28f78585d2b17fdbe070726b2 100644 (file)
@@ -17,7 +17,7 @@ from music_assistant_models.errors import SetupFailedError
 from music_assistant_models.playback_progress_report import MediaItemPlaybackProgressReport
 from music_assistant_models.provider import ProviderManifest
 
-from music_assistant.helpers.scrobbler import ScrobblerHelper
+from music_assistant.helpers.scrobbler import ScrobblerConfig, ScrobblerHelper
 from music_assistant.mass import MusicAssistant
 from music_assistant.models import ProviderInstanceType
 from music_assistant.models.plugin import PluginProvider
@@ -63,7 +63,7 @@ class ListenBrainzScrobbleProvider(PluginProvider):
         """Call after the provider has been loaded."""
         await super().loaded_in_mass()
 
-        handler = ListenBrainzEventHandler(self._client, self.logger)
+        handler = ListenBrainzEventHandler(self._client, self.logger, self.config)
 
         # subscribe to media_item_played event
         self._on_unload.append(
@@ -83,9 +83,11 @@ class ListenBrainzScrobbleProvider(PluginProvider):
 class ListenBrainzEventHandler(ScrobblerHelper):
     """Handles the event handling."""
 
-    def __init__(self, client: ListenBrainz, logger: logging.Logger) -> None:
+    def __init__(
+        self, client: ListenBrainz, logger: logging.Logger, config: ProviderConfig
+    ) -> None:
         """Initialize."""
-        super().__init__(logger)
+        super().__init__(logger, ScrobblerConfig.create_from_config(config))
         self._client = client
 
     def _make_listen(self, report: MediaItemPlaybackProgressReport) -> Listen:
@@ -94,7 +96,7 @@ class ListenBrainzEventHandler(ScrobblerHelper):
 
         # https://pylistenbrainz.readthedocs.io/en/latest/api_ref.html#class-listen
         return Listen(
-            track_name=report.name,
+            track_name=self.get_name(report),
             artist_name=report.artist,
             artist_mbids=report.artist_mbids,
             release_name=report.album,
@@ -140,6 +142,7 @@ async def get_config_entries(
 ) -> tuple[ConfigEntry, ...]:
     """Return Config entries to setup this provider."""
     return (
+        *ScrobblerConfig.get_shared_config_entries(values),
         ConfigEntry(
             key=CONF_USER_TOKEN,
             type=ConfigEntryType.SECURE_STRING,
index 4ffd9cc238aff18161d72656ba903665d303920b..d174f98c28a9cc7f76cf54e715b703153e4d13c3 100644 (file)
@@ -6,7 +6,7 @@ from music_assistant_models.enums import EventType, MediaType
 from music_assistant_models.event import MassEvent
 from music_assistant_models.playback_progress_report import MediaItemPlaybackProgressReport
 
-from music_assistant.helpers.scrobbler import ScrobblerHelper
+from music_assistant.helpers.scrobbler import ScrobblerConfig, ScrobblerHelper
 
 
 class DummyHandler(ScrobblerHelper):
@@ -15,9 +15,9 @@ class DummyHandler(ScrobblerHelper):
     _tracked = 0
     _now_playing = 0
 
-    def __init__(self, logger: logging.Logger) -> None:
+    def __init__(self, logger: logging.Logger, config: ScrobblerConfig | None = None) -> None:
         """Initialize."""
-        super().__init__(logger)
+        super().__init__(logger, config)
 
     def _is_configured(self) -> bool:
         return True
@@ -87,8 +87,26 @@ async def test_it_does_not_update_now_playing_on_pause() -> None:
     assert handler._now_playing == 0
 
 
+async def test_it_suffixes_the_version_if_enabled_and_available() -> None:
+    """Test that the track version is suffixed to the track name when enabled."""
+    report_with_version = create_report(version="Deluxe Edition").data
+    report_without_version = create_report(version=None).data
+
+    handler = DummyHandler(logging.getLogger(), ScrobblerConfig(suffix_version=True))
+    assert handler.get_name(report_with_version) == "track (Deluxe Edition)"
+    assert handler.get_name(report_without_version) == "track"
+
+    handler = DummyHandler(logging.getLogger(), ScrobblerConfig(suffix_version=False))
+    assert handler.get_name(report_with_version) == "track"
+    assert handler.get_name(report_without_version) == "track"
+
+
 def create_report(
-    duration: int, seconds_played: int, is_playing: bool = True, uri: str = "filesystem://track/1"
+    duration: int = 148,
+    seconds_played: int = 59,
+    is_playing: bool = True,
+    uri: str = "filesystem://track/1",
+    version: str | None = None,
 ) -> MassEvent:
     """Create the MediaItemPlaybackProgressReport and wrap it in a MassEvent."""
     return wrap_event(
@@ -106,6 +124,7 @@ def create_report(
             seconds_played=seconds_played,
             fully_played=duration - seconds_played < 5,
             is_playing=is_playing,
+            version=version,
         )
     )