Skip to content

Commit de8419c

Browse files
authored
Add support for multiple Music Assistant integrations
Add support for multiple Music Assistant integrations
2 parents a627ebc + f2c827b commit de8419c

File tree

8 files changed

+236
-28
lines changed

8 files changed

+236
-28
lines changed

.ruff.toml

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# The contents of this file is based on https://github.com/home-assistant/core/blob/dev/pyproject.toml
22

3-
target-version = "py310"
3+
target-version = "py312"
44
[lint]
55
select = ["ALL"]
66

@@ -9,6 +9,8 @@ select = ["ALL"]
99
ignore = [
1010
"ANN",
1111
"ANN401", # Dynamically typed expressions (typing.Any) are disallowed in {name}
12+
"D203",
13+
"D213",
1214
"D401", # First line of docstring should be in imperative mood
1315
"E501", # line too long
1416
"FBT001", # Boolean positional arg in function definition
@@ -44,9 +46,6 @@ ignore = [
4446
"TD003", # Missing issue link on the line following this TODO
4547
]
4648
".github/*py" = ["INP001"]
47-
"webapp/homeassistant_util_color.py" = ["ALL"]
48-
"webapp/app.py" = ["INP001", "DTZ011", "A002"]
49-
"custom_components/adaptive_lighting/homeassistant_util_color.py" = ["ALL"]
5049

5150
[lint.flake8-pytest-style]
5251
fixture-parentheses = false

custom_components/mass_queue/__init__.py

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,13 @@
2121
from music_assistant_client.exceptions import CannotConnect, InvalidServerVersion
2222
from music_assistant_models.errors import ActionUnavailable, MusicAssistantError
2323

24-
from .actions import get_music_assistant_client, setup_controller_and_actions
24+
from .actions import (
25+
MassQueueActions,
26+
get_music_assistant_client,
27+
setup_controller_and_actions,
28+
)
2529
from .const import DOMAIN, LOGGER
30+
from .services import register_actions
2631

2732
if TYPE_CHECKING:
2833
from homeassistant.core import HomeAssistant
@@ -35,20 +40,20 @@
3540

3641
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
3742

38-
type MusicAssistantConfigEntry = ConfigEntry[MusicAssistantEntryData]
43+
type MusicAssistantConfigEntry = ConfigEntry[MusicAssistantQueueEntryData]
3944

4045

4146
@dataclass
42-
class MusicAssistantEntryData:
47+
class MusicAssistantQueueEntryData:
4348
"""Hold Mass data for the config entry."""
4449

4550
mass: MusicAssistantClient
51+
actions: MassQueueActions
4652
listen_task: asyncio.Task
4753

4854

4955
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: ARG001
5056
"""Set up the Music Assistant component."""
51-
setup_controller_and_actions(hass)
5257
return True
5358

5459

@@ -111,7 +116,9 @@ async def on_hass_stop(event: Event) -> None: # noqa: ARG001
111116
raise ConfigEntryNotReady(exc) from err
112117

113118
# store the listen task and mass client in the entry data
114-
entry.runtime_data = MusicAssistantEntryData(mass, listen_task)
119+
actions = await setup_controller_and_actions(hass, mass)
120+
register_actions(hass)
121+
entry.runtime_data = MusicAssistantQueueEntryData(mass, actions, listen_task)
115122

116123
# If the listen task is already failed, we need to raise ConfigEntryNotReady
117124
if listen_task.done() and (listen_error := listen_task.exception()) is not None:
@@ -156,7 +163,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
156163
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
157164

158165
if unload_ok:
159-
mass_entry_data: MusicAssistantEntryData = entry.runtime_data
166+
mass_entry_data: MusicAssistantQueueEntryData = entry.runtime_data
160167
mass_entry_data.listen_task.cancel()
161168
await mass_entry_data.mass.disconnect()
162169

custom_components/mass_queue/actions.py

Lines changed: 4 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
ATTR_MEDIA_TITLE,
2929
ATTR_OFFSET,
3030
ATTR_PLAYER_ENTITY,
31+
ATTR_QUEUE_ID,
3132
ATTR_QUEUE_ITEM_ID,
3233
DEFAULT_QUEUE_ITEMS_LIMIT,
3334
DEFAULT_QUEUE_ITEMS_OFFSET,
@@ -129,9 +130,7 @@ def register_actions(self) -> None:
129130

130131
def get_queue_id(self, entity_id: str):
131132
"""Get the queue ID for a player."""
132-
registry = er.async_get(self._hass)
133-
entity = registry.async_get(entity_id)
134-
return entity.unique_id
133+
return self._hass.states.get(entity_id).attributes[ATTR_QUEUE_ID]
135134

136135
async def get_queue_index(self, entity_id: str):
137136
"""Get the current index of the queue."""
@@ -251,15 +250,6 @@ async def move_queue_item_next(self, call: ServiceCall) -> ServiceResponse:
251250
)
252251

253252

254-
@callback
255-
def get_music_assistant_client_boostrap(hass: HomeAssistant) -> MusicAssistantClient:
256-
"""Get Music Assistant Client by finding its domain."""
257-
mass_domain = "music_assistant"
258-
entries = hass.config_entries.async_entries()
259-
config_entry = [entry for entry in entries if entry.domain == mass_domain][0]
260-
return config_entry.runtime_data.mass
261-
262-
263253
@callback
264254
def get_music_assistant_client(
265255
hass: HomeAssistant,
@@ -289,14 +279,11 @@ def _get_music_assistant_client(
289279

290280

291281
@callback
292-
def setup_controller_and_actions(
282+
async def setup_controller_and_actions(
293283
hass: HomeAssistant,
294-
mass_client: MusicAssistantClient | None = None,
284+
mass_client: MusicAssistantClient,
295285
) -> MassQueueActions:
296286
"""Initialize client and actions class, add actions to Home Assistant."""
297-
if mass_client is None:
298-
mass_client = get_music_assistant_client_boostrap(hass)
299287
actions = MassQueueActions(hass, mass_client)
300288
actions.setup_controller()
301-
actions.register_actions()
302289
return actions

custom_components/mass_queue/const.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
SERVICE_MOVE_QUEUE_ITEM_NEXT = "move_queue_item_next"
1313
SERVICE_SEND_COMMAND = "send_command"
1414

15+
ATTR_QUEUE_ID = "active_queue"
1516
ATTR_CONFIG_ENTRY_ID = "config_entry_id"
1617
ATTR_LIMIT = "limit"
1718
ATTR_LIMIT_AFTER = "limit_after"

custom_components/mass_queue/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,6 @@
1111
"issue_tracker": "https://github.com/droans/mass_queue/issues",
1212
"requirements": ["music-assistant-client"],
1313
"ssdp": [],
14-
"version": "0.4.2",
14+
"version": "0.5.0",
1515
"zeroconf": ["_mass._tcp.local."]
1616
}

custom_components/mass_queue/schemas.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from .const import (
99
ATTR_COMMAND,
10+
ATTR_CONFIG_ENTRY_ID,
1011
ATTR_DATA,
1112
ATTR_LIMIT,
1213
ATTR_LIMIT_AFTER,
@@ -86,5 +87,6 @@
8687
{
8788
vol.Required(ATTR_COMMAND): str,
8889
vol.Optional(ATTR_DATA, default={}): dict,
90+
vol.Required(ATTR_CONFIG_ENTRY_ID): str,
8991
},
9092
)
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
"""Service actions for mass_queue."""
2+
3+
from __future__ import annotations
4+
5+
from typing import TYPE_CHECKING
6+
7+
from homeassistant.config_entries import ConfigEntryState
8+
from homeassistant.core import (
9+
HomeAssistant,
10+
ServiceCall,
11+
SupportsResponse,
12+
callback,
13+
)
14+
from homeassistant.exceptions import ServiceValidationError
15+
from homeassistant.helpers import entity_registry as er
16+
17+
from .const import (
18+
ATTR_CONFIG_ENTRY_ID,
19+
ATTR_PLAYER_ENTITY,
20+
DOMAIN,
21+
SERVICE_GET_QUEUE_ITEMS,
22+
SERVICE_MOVE_QUEUE_ITEM_DOWN,
23+
SERVICE_MOVE_QUEUE_ITEM_NEXT,
24+
SERVICE_MOVE_QUEUE_ITEM_UP,
25+
SERVICE_PLAY_QUEUE_ITEM,
26+
SERVICE_REMOVE_QUEUE_ITEM,
27+
SERVICE_SEND_COMMAND,
28+
)
29+
from .schemas import (
30+
MOVE_QUEUE_ITEM_DOWN_SERVICE_SCHEMA,
31+
MOVE_QUEUE_ITEM_NEXT_SERVICE_SCHEMA,
32+
MOVE_QUEUE_ITEM_UP_SERVICE_SCHEMA,
33+
PLAY_QUEUE_ITEM_SERVICE_SCHEMA,
34+
QUEUE_ITEMS_SERVICE_SCHEMA,
35+
REMOVE_QUEUE_ITEM_SERVICE_SCHEMA,
36+
SEND_COMMAND_SERVICE_SCHEMA,
37+
)
38+
39+
if TYPE_CHECKING:
40+
from music_assistant_client import MusicAssistantClient
41+
42+
from . import MassQueueEntryData
43+
44+
45+
@callback
46+
def register_actions(hass) -> None:
47+
"""Registers actions with Home Assistant."""
48+
hass.services.async_register(
49+
DOMAIN,
50+
SERVICE_GET_QUEUE_ITEMS,
51+
get_queue_items,
52+
schema=QUEUE_ITEMS_SERVICE_SCHEMA,
53+
supports_response=SupportsResponse.ONLY,
54+
)
55+
hass.services.async_register(
56+
DOMAIN,
57+
SERVICE_MOVE_QUEUE_ITEM_DOWN,
58+
move_queue_item_down,
59+
schema=MOVE_QUEUE_ITEM_DOWN_SERVICE_SCHEMA,
60+
supports_response=SupportsResponse.NONE,
61+
)
62+
hass.services.async_register(
63+
DOMAIN,
64+
SERVICE_MOVE_QUEUE_ITEM_NEXT,
65+
move_queue_item_next,
66+
schema=MOVE_QUEUE_ITEM_NEXT_SERVICE_SCHEMA,
67+
supports_response=SupportsResponse.NONE,
68+
)
69+
hass.services.async_register(
70+
DOMAIN,
71+
SERVICE_MOVE_QUEUE_ITEM_UP,
72+
move_queue_item_up,
73+
schema=MOVE_QUEUE_ITEM_UP_SERVICE_SCHEMA,
74+
supports_response=SupportsResponse.NONE,
75+
)
76+
hass.services.async_register(
77+
DOMAIN,
78+
SERVICE_PLAY_QUEUE_ITEM,
79+
play_queue_item,
80+
schema=PLAY_QUEUE_ITEM_SERVICE_SCHEMA,
81+
supports_response=SupportsResponse.NONE,
82+
)
83+
hass.services.async_register(
84+
DOMAIN,
85+
SERVICE_REMOVE_QUEUE_ITEM,
86+
remove_queue_item,
87+
schema=REMOVE_QUEUE_ITEM_SERVICE_SCHEMA,
88+
supports_response=SupportsResponse.NONE,
89+
)
90+
hass.services.async_register(
91+
DOMAIN,
92+
SERVICE_SEND_COMMAND,
93+
send_command,
94+
schema=SEND_COMMAND_SERVICE_SCHEMA,
95+
supports_response=SupportsResponse.OPTIONAL,
96+
)
97+
98+
99+
def _get_mass_entity_config_entry_id(hass, entity_id):
100+
"""Helper to grab config entry ID from entity ID."""
101+
registry = er.async_get(hass)
102+
return registry.async_get(entity_id).config_entry_id
103+
104+
105+
@callback
106+
def _get_config_entry(
107+
hass: HomeAssistant,
108+
config_entry_id: str,
109+
) -> MusicAssistantClient:
110+
"""Get Music Assistant Client from config_entry_id."""
111+
entry: MassQueueEntryData | None
112+
if not (entry := hass.config_entries.async_get_entry(config_entry_id)):
113+
exc = "Entry not found."
114+
raise ServiceValidationError(exc)
115+
if entry.state is not ConfigEntryState.LOADED:
116+
exc = "Entry not loaded"
117+
raise ServiceValidationError(exc)
118+
return entry
119+
120+
121+
def get_mass_entry(hass, entity_id):
122+
"""Helper function to pull MA Config Entry."""
123+
config_id = _get_mass_entity_config_entry_id(hass, entity_id)
124+
return _get_config_entry(hass, config_id)
125+
126+
127+
def _get_mass_queue_entries(hass):
128+
"""Gets all entries for mass_queue domain."""
129+
entries = hass.config_entries.async_entries()
130+
return [entry for entry in entries if entry.domain == "mass_queue"]
131+
132+
133+
def find_mass_queue_entry(hass, mass_url):
134+
"""Finds the mass_queue entry for the given MA URL."""
135+
entries = _get_mass_queue_entries(hass)
136+
for entry in entries:
137+
entry_url = entry.runtime_data.mass.connection.ws_server_url
138+
if entry_url == mass_url:
139+
return entry
140+
msg = f"Cannot find entry for Music Assistant at {mass_url}"
141+
raise ServiceValidationError(msg)
142+
143+
144+
def get_entity_actions_controller(hass, entity_id):
145+
"""Gets the actions for the selected entity."""
146+
mass_entry = get_mass_entry(hass, entity_id)
147+
mass = mass_entry.runtime_data.mass.connection.ws_server_url
148+
mass_queue_entry = find_mass_queue_entry(hass, mass)
149+
return mass_queue_entry.runtime_data.actions
150+
151+
152+
async def get_queue_items(call: ServiceCall):
153+
"""Service wrapper to get queue items."""
154+
entity_id = call.data[ATTR_PLAYER_ENTITY]
155+
hass = call.hass
156+
actions = get_entity_actions_controller(hass, entity_id)
157+
return await actions.get_queue_items(call)
158+
159+
160+
async def move_queue_item_down(call: ServiceCall):
161+
"""Service wrapper to move queue item down."""
162+
entity_id = call.data[ATTR_PLAYER_ENTITY]
163+
hass = call.hass
164+
actions = get_entity_actions_controller(hass, entity_id)
165+
return await actions.move_queue_item_down(call)
166+
167+
168+
async def move_queue_item_next(call: ServiceCall):
169+
"""Service wrapper to move queue item next."""
170+
entity_id = call.data[ATTR_PLAYER_ENTITY]
171+
hass = call.hass
172+
actions = get_entity_actions_controller(hass, entity_id)
173+
return await actions.move_queue_item_next(call)
174+
175+
176+
async def move_queue_item_up(call: ServiceCall):
177+
"""Service wrapper to move queue item up."""
178+
entity_id = call.data[ATTR_PLAYER_ENTITY]
179+
hass = call.hass
180+
actions = get_entity_actions_controller(hass, entity_id)
181+
return await actions.move_queue_item_up(call)
182+
183+
184+
async def play_queue_item(call: ServiceCall):
185+
"""Service wrapper to play a queue item."""
186+
entity_id = call.data[ATTR_PLAYER_ENTITY]
187+
hass = call.hass
188+
actions = get_entity_actions_controller(hass, entity_id)
189+
return await actions.play_queue_item(call)
190+
191+
192+
async def remove_queue_item(call: ServiceCall):
193+
"""Service wrapper to remove a queue item."""
194+
entity_id = call.data[ATTR_PLAYER_ENTITY]
195+
hass = call.hass
196+
actions = get_entity_actions_controller(hass, entity_id)
197+
return await actions.remove_queue_item(call)
198+
199+
200+
async def send_command(call: ServiceCall):
201+
"""Service wrapper to send command to Music Assistant."""
202+
entry_id = call.data[ATTR_CONFIG_ENTRY_ID]
203+
hass = call.hass
204+
entry = hass.config_entries.async_get_entry(entry_id)
205+
actions = entry.runtime_data.actions
206+
return await actions.send_command(call)

custom_components/mass_queue/services.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,3 +141,9 @@ send_command:
141141
default: {}
142142
selector:
143143
object:
144+
config_entry_id:
145+
name: Config Entry ID
146+
required: true
147+
selector:
148+
config_entry:
149+
integration: mass_queue

0 commit comments

Comments
 (0)