Implement data coordinator for LastFM (#96942)
Co-authored-by: G Johansson <goran.johansson@shiftit.se>pull/97164/head
parent
e96bff1674
commit
fe66c3414b
|
@ -4,12 +4,17 @@ from __future__ import annotations
|
|||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import PLATFORMS
|
||||
from .const import DOMAIN, PLATFORMS
|
||||
from .coordinator import LastFMDataUpdateCoordinator
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up lastfm from a config entry."""
|
||||
|
||||
coordinator = LastFMDataUpdateCoordinator(hass)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
entry.async_on_unload(entry.add_update_listener(update_listener))
|
||||
|
||||
|
|
|
@ -0,0 +1,89 @@
|
|||
"""DataUpdateCoordinator for the LastFM integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
|
||||
from pylast import LastFMNetwork, PyLastError, Track
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_API_KEY
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import (
|
||||
CONF_USERS,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
)
|
||||
|
||||
|
||||
def format_track(track: Track | None) -> str | None:
|
||||
"""Format the track."""
|
||||
if track is None:
|
||||
return None
|
||||
return f"{track.artist} - {track.title}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class LastFMUserData:
|
||||
"""Data holder for LastFM data."""
|
||||
|
||||
play_count: int
|
||||
image: str
|
||||
now_playing: str | None
|
||||
top_track: str | None
|
||||
last_track: str | None
|
||||
|
||||
|
||||
class LastFMDataUpdateCoordinator(DataUpdateCoordinator[dict[str, LastFMUserData]]):
|
||||
"""A LastFM Data Update Coordinator."""
|
||||
|
||||
config_entry: ConfigEntry
|
||||
_client: LastFMNetwork
|
||||
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
"""Initialize the LastFM data coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
LOGGER,
|
||||
name=DOMAIN,
|
||||
update_interval=timedelta(seconds=30),
|
||||
)
|
||||
self._client = LastFMNetwork(api_key=self.config_entry.options[CONF_API_KEY])
|
||||
|
||||
async def _async_update_data(self) -> dict[str, LastFMUserData]:
|
||||
res = {}
|
||||
for username in self.config_entry.options[CONF_USERS]:
|
||||
data = await self.hass.async_add_executor_job(self._get_user_data, username)
|
||||
if data is not None:
|
||||
res[username] = data
|
||||
if not res:
|
||||
raise UpdateFailed
|
||||
return res
|
||||
|
||||
def _get_user_data(self, username: str) -> LastFMUserData | None:
|
||||
user = self._client.get_user(username)
|
||||
try:
|
||||
play_count = user.get_playcount()
|
||||
image = user.get_image()
|
||||
now_playing = format_track(user.get_now_playing())
|
||||
top_tracks = user.get_top_tracks(limit=1)
|
||||
last_tracks = user.get_recent_tracks(limit=1)
|
||||
except PyLastError as exc:
|
||||
if self.last_update_success:
|
||||
LOGGER.error("LastFM update for %s failed: %r", username, exc)
|
||||
return None
|
||||
top_track = None
|
||||
if len(top_tracks) > 0:
|
||||
top_track = format_track(top_tracks[0].item)
|
||||
last_track = None
|
||||
if len(last_tracks) > 0:
|
||||
last_track = format_track(last_tracks[0].track)
|
||||
return LastFMUserData(
|
||||
play_count,
|
||||
image,
|
||||
now_playing,
|
||||
top_track,
|
||||
last_track,
|
||||
)
|
|
@ -2,8 +2,8 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from typing import Any
|
||||
|
||||
from pylast import LastFMNetwork, PyLastError, Track, User
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity
|
||||
|
@ -16,6 +16,9 @@ from homeassistant.helpers.entity import DeviceInfo
|
|||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
from homeassistant.helpers.update_coordinator import (
|
||||
CoordinatorEntity,
|
||||
)
|
||||
|
||||
from .const import (
|
||||
ATTR_LAST_PLAYED,
|
||||
|
@ -24,9 +27,9 @@ from .const import (
|
|||
CONF_USERS,
|
||||
DEFAULT_NAME,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
STATE_NOT_SCROBBLING,
|
||||
)
|
||||
from .coordinator import LastFMDataUpdateCoordinator, LastFMUserData
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
|
||||
{
|
||||
|
@ -36,11 +39,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
|
|||
)
|
||||
|
||||
|
||||
def format_track(track: Track) -> str:
|
||||
"""Format the track."""
|
||||
return f"{track.artist} - {track.title}"
|
||||
|
||||
|
||||
async def async_setup_platform(
|
||||
hass: HomeAssistant,
|
||||
config: ConfigType,
|
||||
|
@ -78,61 +76,76 @@ async def async_setup_entry(
|
|||
) -> None:
|
||||
"""Initialize the entries."""
|
||||
|
||||
lastfm_api = LastFMNetwork(api_key=entry.options[CONF_API_KEY])
|
||||
coordinator: LastFMDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
async_add_entities(
|
||||
(
|
||||
LastFmSensor(lastfm_api.get_user(user), entry.entry_id)
|
||||
for user in entry.options[CONF_USERS]
|
||||
LastFmSensor(coordinator, username, entry.entry_id)
|
||||
for username in entry.options[CONF_USERS]
|
||||
),
|
||||
True,
|
||||
)
|
||||
|
||||
|
||||
class LastFmSensor(SensorEntity):
|
||||
class LastFmSensor(CoordinatorEntity[LastFMDataUpdateCoordinator], SensorEntity):
|
||||
"""A class for the Last.fm account."""
|
||||
|
||||
_attr_attribution = "Data provided by Last.fm"
|
||||
_attr_icon = "mdi:radio-fm"
|
||||
|
||||
def __init__(self, user: User, entry_id: str) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: LastFMDataUpdateCoordinator,
|
||||
username: str,
|
||||
entry_id: str,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
self._user = user
|
||||
self._attr_unique_id = hashlib.sha256(user.name.encode("utf-8")).hexdigest()
|
||||
self._attr_name = user.name
|
||||
super().__init__(coordinator)
|
||||
self._username = username
|
||||
self._attr_unique_id = hashlib.sha256(username.encode("utf-8")).hexdigest()
|
||||
self._attr_name = username
|
||||
self._attr_device_info = DeviceInfo(
|
||||
configuration_url="https://www.last.fm",
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
identifiers={(DOMAIN, f"{entry_id}_{self._attr_unique_id}")},
|
||||
manufacturer=DEFAULT_NAME,
|
||||
name=f"{DEFAULT_NAME} {user.name}",
|
||||
name=f"{DEFAULT_NAME} {username}",
|
||||
)
|
||||
|
||||
def update(self) -> None:
|
||||
"""Update device state."""
|
||||
self._attr_native_value = STATE_NOT_SCROBBLING
|
||||
try:
|
||||
play_count = self._user.get_playcount()
|
||||
self._attr_entity_picture = self._user.get_image()
|
||||
now_playing = self._user.get_now_playing()
|
||||
top_tracks = self._user.get_top_tracks(limit=1)
|
||||
last_tracks = self._user.get_recent_tracks(limit=1)
|
||||
except PyLastError as exc:
|
||||
self._attr_available = False
|
||||
LOGGER.error("Failed to load LastFM user `%s`: %r", self._user.name, exc)
|
||||
return
|
||||
self._attr_available = True
|
||||
if now_playing:
|
||||
self._attr_native_value = format_track(now_playing)
|
||||
self._attr_extra_state_attributes = {
|
||||
@property
|
||||
def user_data(self) -> LastFMUserData | None:
|
||||
"""Returns the user from the coordinator."""
|
||||
return self.coordinator.data.get(self._username)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""If user not found in coordinator, entity is unavailable."""
|
||||
return super().available and self.user_data is not None
|
||||
|
||||
@property
|
||||
def entity_picture(self) -> str | None:
|
||||
"""Return user avatar."""
|
||||
if self.user_data and self.user_data.image is not None:
|
||||
return self.user_data.image
|
||||
return None
|
||||
|
||||
@property
|
||||
def native_value(self) -> str:
|
||||
"""Return value of sensor."""
|
||||
if self.user_data and self.user_data.now_playing is not None:
|
||||
return self.user_data.now_playing
|
||||
return STATE_NOT_SCROBBLING
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
"""Return state attributes."""
|
||||
play_count = None
|
||||
last_track = None
|
||||
top_track = None
|
||||
if self.user_data:
|
||||
play_count = self.user_data.play_count
|
||||
last_track = self.user_data.last_track
|
||||
top_track = self.user_data.top_track
|
||||
return {
|
||||
ATTR_PLAY_COUNT: play_count,
|
||||
ATTR_LAST_PLAYED: None,
|
||||
ATTR_TOP_PLAYED: None,
|
||||
ATTR_LAST_PLAYED: last_track,
|
||||
ATTR_TOP_PLAYED: top_track,
|
||||
}
|
||||
if len(last_tracks) > 0:
|
||||
self._attr_extra_state_attributes[ATTR_LAST_PLAYED] = format_track(
|
||||
last_tracks[0].track
|
||||
)
|
||||
if len(top_tracks) > 0:
|
||||
self._attr_extra_state_attributes[ATTR_TOP_PLAYED] = format_track(
|
||||
top_tracks[0].item
|
||||
)
|
||||
|
|
|
@ -75,7 +75,7 @@ class MockUser:
|
|||
|
||||
def get_image(self) -> str:
|
||||
"""Get mock image."""
|
||||
return ""
|
||||
return "image"
|
||||
|
||||
def get_recent_tracks(self, limit: int) -> list[MockLastTrack]:
|
||||
"""Get mock recent tracks."""
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'attribution': 'Data provided by Last.fm',
|
||||
'entity_picture': '',
|
||||
'entity_picture': 'image',
|
||||
'friendly_name': 'testaccount1',
|
||||
'icon': 'mdi:radio-fm',
|
||||
'last_played': 'artist - title',
|
||||
|
@ -21,7 +21,7 @@
|
|||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'attribution': 'Data provided by Last.fm',
|
||||
'entity_picture': '',
|
||||
'entity_picture': 'image',
|
||||
'friendly_name': 'testaccount1',
|
||||
'icon': 'mdi:radio-fm',
|
||||
'last_played': None,
|
||||
|
@ -36,16 +36,5 @@
|
|||
})
|
||||
# ---
|
||||
# name: test_sensors[not_found_user]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'attribution': 'Data provided by Last.fm',
|
||||
'friendly_name': 'testaccount1',
|
||||
'icon': 'mdi:radio-fm',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.testaccount1',
|
||||
'last_changed': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'unavailable',
|
||||
})
|
||||
None
|
||||
# ---
|
||||
|
|
|
@ -14,7 +14,7 @@ from homeassistant.core import HomeAssistant
|
|||
from homeassistant.helpers import issue_registry as ir
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from . import API_KEY, USERNAME_1
|
||||
from . import API_KEY, USERNAME_1, MockUser
|
||||
from .conftest import ComponentSetup
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
@ -28,7 +28,7 @@ LEGACY_CONFIG = {
|
|||
|
||||
async def test_legacy_migration(hass: HomeAssistant) -> None:
|
||||
"""Test migration from yaml to config flow."""
|
||||
with patch("pylast.User", return_value=None):
|
||||
with patch("pylast.User", return_value=MockUser()):
|
||||
assert await async_setup_component(hass, Platform.SENSOR, LEGACY_CONFIG)
|
||||
await hass.async_block_till_done()
|
||||
entries = hass.config_entries.async_entries(DOMAIN)
|
||||
|
|
Loading…
Reference in New Issue