From: Willem-Jan Zijderveld Date: Fri, 28 Nov 2025 22:46:59 +0000 (+0100) Subject: Add Version suffix config to scrobble providers (#2709) X-Git-Url: https://git.kitaultman.com/?a=commitdiff_plain;h=00c53f27a82a305866a906d41355305993b9efec;p=music-assistant-server.git Add Version suffix config to scrobble providers (#2709) --- diff --git a/music_assistant/helpers/scrobbler.py b/music_assistant/helpers/scrobbler.py index 197c7646..81af9a34 100644 --- a/music_assistant/helpers/scrobbler.py +++ b/music_assistant/helpers/scrobbler.py @@ -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))) diff --git a/music_assistant/providers/lastfm_scrobble/__init__.py b/music_assistant/providers/lastfm_scrobble/__init__.py index 1f4dc5a1..306e0853 100644 --- a/music_assistant/providers/lastfm_scrobble/__init__.py +++ b/music_assistant/providers/lastfm_scrobble/__init__.py @@ -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, diff --git a/music_assistant/providers/listenbrainz_scrobble/__init__.py b/music_assistant/providers/listenbrainz_scrobble/__init__.py index 8f4e3c52..33dfe99f 100644 --- a/music_assistant/providers/listenbrainz_scrobble/__init__.py +++ b/music_assistant/providers/listenbrainz_scrobble/__init__.py @@ -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, diff --git a/tests/core/test_scrobbler.py b/tests/core/test_scrobbler.py index 4ffd9cc2..d174f98c 100644 --- a/tests/core/test_scrobbler.py +++ b/tests/core/test_scrobbler.py @@ -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, ) )