Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Basic AudioScrobbler plugin #1850

Draft
wants to merge 4 commits into
base: dev
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
271 changes: 271 additions & 0 deletions music_assistant/providers/audioscrobbler/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
"""Allows scrobbling of tracks with the help of PyLast."""

import logging
import time
from typing import TYPE_CHECKING

import pylast
from music_assistant_models.config_entries import (
ConfigEntry,
ConfigValueOption,
ConfigValueType,
ProviderConfig,
)
from music_assistant_models.constants import SECURE_STRING_SUBSTITUTE
from music_assistant_models.enums import ConfigEntryType, EventType, MediaType, PlayerState
from music_assistant_models.errors import LoginFailed, SetupFailedError
from music_assistant_models.event import MassEvent
from music_assistant_models.provider import ProviderManifest

if TYPE_CHECKING:
from music_assistant_models.media_items import Track
from music_assistant_models.player_queue import PlayerQueue

from music_assistant import MusicAssistant
from music_assistant.constants import MASS_LOGGER_NAME
from music_assistant.helpers.auth import AuthenticationHelper
from music_assistant.models import ProviderInstanceType
from music_assistant.models.plugin import PluginProvider


async def setup(
mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
) -> ProviderInstanceType:
"""Initialize provider(instance) with given configuration."""
return ScrobblerProvider(mass, manifest, config)


class ScrobblerProvider(PluginProvider):
"""Plugin provider to support scrobbling of tracks."""

_network: pylast._Network = None

def __init__(
self, mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
) -> None:
"""Initialize AudioScrobbler."""
super().__init__(mass, manifest, config)
if self.logger.level == logging.DEBUG:
pylast.logger.setLevel(logging.DEBUG)
wjzijderveld marked this conversation as resolved.
Show resolved Hide resolved

def _get_network_config(self) -> dict[str, ConfigValueType]:
return {
CONF_API_KEY: self.config.get_value(CONF_API_KEY),
CONF_API_SECRET: self.config.get_value(CONF_API_SECRET),
CONF_PROVIDER: self.config.get_value(CONF_PROVIDER),
CONF_USERNAME: self.config.get_value(CONF_USERNAME),
CONF_SESSION_KEY: self.config.get_value(CONF_SESSION_KEY),
}

async def loaded_in_mass(self) -> None:
"""Call after the provider has been loaded."""
await super().loaded_in_mass()

if self.config.get_value(CONF_SESSION_KEY):
self._network = _get_network(self._get_network_config())

# could be interesting for a "scrobble after X seconds" feature
# self.mass.subscribe(self._on_mass_queue_time_updated, EventType.QUEUE_TIME_UPDATED)
self.mass.subscribe(self._on_mass_queue_updated, EventType.QUEUE_UPDATED)
self.mass.subscribe(self._on_mass_media_item_played, EventType.MEDIA_ITEM_PLAYED)

async def _on_mass_queue_updated(self, event: MassEvent) -> None:
marcelveldt marked this conversation as resolved.
Show resolved Hide resolved
"""Player has updated, update nowPlaying."""
if self._network is None:
return

queue: PlayerQueue = event.data
if queue.state == PlayerState.PLAYING and queue.current_item.media_type == MediaType.TRACK:
marcelveldt marked this conversation as resolved.
Show resolved Hide resolved
track: Track = queue.current_item.media_item
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have a helper called is_track which implements a typing TypeGuard. If you use it here you won't need the explict type.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've used it in 2 places, but honestly the typing support in Python confuses me a bit and I probably don't have my editor setup correct to understand the TypeGuard, so I still left the : Track typehint here.

try:
self._network.update_now_playing(
artist=track.artist_str,
title=track.name,
mbid=track.mbid,
album=track.album.name if track.album else None,
)
except Exception as err:
self.logger.exception(err)

async def _on_mass_media_item_played(self, event: MassEvent) -> None:
"""Media item has finished playing, we'll scrobble the track."""
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe the Subsonic provider scrobbles if half the track is played. Is there a standard for this? (Reference: music-assistant/support#3203 (comment) )

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's one of the things I want to look at, 50% is fairly common, but often configurable.
For now I just focussed on the generic config and basic events, as currently it's not super easy to trigger something based on percentage played.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_on_mass_media_item_played will always be triggered and includes the amount of seconds played.
So you can make your own logic together with the item's duration

Check the event.data of this event, it has all the info you need.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've added a basic check for now, but usually the approach is a bit different. For example, often you'd actually detect length that's being played, so skipping ahead would not cause a scrobble.
And with the current setup, when a song gets paused for example, played and paused near the end of the song, it gets scrobbled every time. I could try to detect that, but having that work while also accounting for songs that are on repeat would be annoying.

These are all nice to haves though, we can iterate later if we want.

if self._network is None:
return

item = await self.mass.music.get_item_by_uri(event.object_id)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

event.data also contains a media_item attribute so you wont have to fetch it , saves a potential api call

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

doh, no idea how I missed that, I'll update that, thanks!

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I figured out why I missed that. Because it's not true :P data["media_item'] contains the reference as well, not the item. I played around a bit with passing the actual item, but I got serialization errors.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I updated the object with the info present. We must prevent fetching full item details where/if we can to save api calls

if item.media_type is not MediaType.TRACK:
return

try:
track: Track = item
self._network.scrobble(
artist=track.artist_str,
title=track.name,
timestamp=time.time(),
mbid=track.mbid,
album=track.album.name if track.album else None,
)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is doing a blocking call - you need to either wrap the calls to this library into an executor or do the calls yourself using an async web client.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, it's been a while since I worked in an eventlooped system. Combined with my lack of python experience I just saw async and assumed all was good 😂

except Exception as err:
self.logger.exception(err)


# configuration keys
CONF_API_KEY = "_api_key"
CONF_API_SECRET = "_api_secret"
CONF_SESSION_KEY = "_api_session_key"
CONF_USERNAME = "_username"
CONF_PROVIDER = "_provider"

# configuration actions
CONF_ACTION_AUTH = "_auth"

# available networks
CONF_OPTION_LASTFM = "lastfm"
CONF_OPTION_LIBREFM = "librefm"


async def get_config_entries(
mass: MusicAssistant,
instance_id: str | None = None, # noqa: ARG001
action: str | None = None,
values: dict[str, ConfigValueType] | None = None,
) -> tuple[ConfigEntry, ...]:
"""
Return Config entries to setup this provider.

instance_id: id of an existing provider instance (None if new instance setup).
action: [optional] action key called from config entries UI.
values: the (intermediate) raw values for config entries sent with the action.
"""
logger = logging.getLogger(MASS_LOGGER_NAME).getChild("audioscrobbler")

provider: str = values.get(CONF_PROVIDER)
if values is None or not values.get(CONF_PROVIDER):
provider = CONF_OPTION_LASTFM

# collect all config entries to show
entries: list[ConfigEntry] = [
ConfigEntry(
key=CONF_PROVIDER,
type=ConfigEntryType.STRING,
label="Provider",
required=True,
description="The endpoint to use, defaults to Last.fm",
options=(
ConfigValueOption(title="Last.FM", value=CONF_OPTION_LASTFM),
ConfigValueOption(title="LibreFM", value=CONF_OPTION_LIBREFM),
),
default_value=provider,
value=provider,
),
ConfigEntry(
key=CONF_API_KEY,
type=ConfigEntryType.SECURE_STRING,
label="API Key",
required=True,
value=values.get(CONF_API_KEY) if values else None,
),
ConfigEntry(
key=CONF_API_SECRET,
type=ConfigEntryType.SECURE_STRING,
label="Shared secret",
required=True,
value=values.get(CONF_API_SECRET) if values else None,
),
]

if action == CONF_ACTION_AUTH:
async with AuthenticationHelper(mass, str(values["session_id"])) as auth_helper:
network = _get_network(values)
skg = pylast.SessionKeyGenerator(network)

# pylast says it does web auth, but actually does desktop auth
# so we need to do some URL juggling ourselves
# to get a proper web auth flow with a callback
url = (
f"{network.homepage}/api/auth/"
f"?api_key={network.api_key}"
f"&cb={auth_helper.callback_url}"
)

logger.info("authenticating on %s", url)
response = await auth_helper.authenticate(url)
if not response["token"]:
raise LoginFailed(f"no token available in {provider} response")

session_key, username = skg.get_web_auth_session_key_username(
url, str(response["token"])
)
values[CONF_USERNAME] = username
values[CONF_SESSION_KEY] = session_key

entries += [
ConfigEntry(
key="save_reminder",
type=ConfigEntryType.ALERT,
required=False,
default_value=None,
label=(
f"Successfully logged in as {username},",
"don't forget to hit save to complete the setup",
),
),
]

if values is None or not values.get(CONF_SESSION_KEY):
# unable to use the encrypted values during an action
# so we make sure fresh credentials need to be entered
values[CONF_API_KEY] = None
values[CONF_API_SECRET] = None
entries += [
ConfigEntry(
key=CONF_ACTION_AUTH,
type=ConfigEntryType.ACTION,
label=f"Authorize with {provider}",
action=CONF_ACTION_AUTH,
),
]

entries += [
ConfigEntry(
key=CONF_USERNAME,
type=ConfigEntryType.STRING,
label="Logged in user",
hidden=True,
value=values.get(CONF_USERNAME) if values else None,
),
ConfigEntry(
key=CONF_SESSION_KEY,
type=ConfigEntryType.SECURE_STRING,
label="Session key",
hidden=True,
required=False,
value=values.get(CONF_SESSION_KEY) if values else None,
),
]

return tuple(entries)


def _get_network(config: dict[str, ConfigValueType]) -> pylast._Network:
key = config.get(CONF_API_KEY)
secret = config.get(CONF_API_SECRET)
session_key = config.get(CONF_SESSION_KEY)

assert key
assert key != SECURE_STRING_SUBSTITUTE
assert secret
assert secret != SECURE_STRING_SUBSTITUTE

if not key or not secret:
raise SetupFailedError("API Key and Secret need to be set")

match config.get(CONF_PROVIDER).lower():
case "lastfm":
return pylast.LastFMNetwork(
key, secret, username=config.get(CONF_USERNAME), session_key=session_key
)
case "librefm":
return pylast.LibreFMNetwork(
key, secret, username=config.get(CONF_USERNAME), session_key=session_key
)
3 changes: 3 additions & 0 deletions music_assistant/providers/audioscrobbler/icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
11 changes: 11 additions & 0 deletions music_assistant/providers/audioscrobbler/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"type": "plugin",
"domain": "audioscrobbler",
"name": "AudioScrobbler",
"description": "Scrobble your music to Last.fm and others with a compatible API like Libre.fm",
"codeowners": ["@music-assistant"],
"documentation": "",
"multi_instance": false,
"builtin": false,
"requirements": ["pylast"]
}
1 change: 1 addition & 0 deletions requirements_all.txt
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ py-opensonic==5.2.1
pyblu==2.0.0
PyChromecast==14.0.5
pycryptodome==3.21.0
pylast
wjzijderveld marked this conversation as resolved.
Show resolved Hide resolved
python-fullykiosk==0.0.14
python-slugify==8.0.4
pywidevine==1.8.0
Expand Down
Loading