Skip to content

Commit cd23dc8

Browse files
authored
Merge pull request #52 from droans/download-local-images
Add support to download local images
2 parents 57ba7b8 + 9730a3e commit cd23dc8

File tree

9 files changed

+268
-50
lines changed

9 files changed

+268
-50
lines changed

README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,3 +112,23 @@ media_player.music_assistant_speaker:
112112
## Configuration
113113

114114
The integration should automatically detect the active Music Assistant instance and integration. If it does not, add as you normally would from the "Devices & Services" section in the Home Assistant Settings.
115+
116+
117+
# FAQs
118+
119+
## I use a local provider (eg, Filesystem, Plex, Jellyfin, etc) and my images aren't showing up! What gives?!
120+
121+
Local music providers are a bit different than cloud. When you are using Plex or Jellyfin, the image returned is an HTTP URL for their local IP address. This is problematic - most users access Home Assistant via HTTP**S** and modern browsers prohibit mixed content (insecure content on secure sites). For filesystem providers, it's even more difficult as Music Assistant returns their path on the filesystem instead of a URL.
122+
123+
However, there is a workaround!
124+
125+
You may enable the `download_local` option by navigating to the integration's listing in Home Assistant and selecting the cog next to the entry. When this is enabled, the integration will attempt to download and encode the image for any item which does not have any images marked as `remotely_accessible`.
126+
127+
This option will then return a new attribute for these queue items labeled `local_image_encoded`. Custom cards can then utilize this in their code in place of the image URL.
128+
129+
### WARNINGS
130+
131+
* This is not a cure-all and should not be enabled unless you need it.
132+
* This will not have any effect unless any frontend card supports it.
133+
* Loading the integration and updating the queue WILL take much longer. Each item must be downloaded and converted. This is NOT a quick process. Depending on your server, this may take between 2-20 seconds per item.
134+
* This requires that Home Assistant can directly access the Music Assistant server along with the local provider.

custom_components/mass_queue/__init__.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
)
3030
from .const import DOMAIN, LOGGER
3131
from .services import register_actions
32-
from .utils import download_images
32+
from .utils import api_download_and_encode_image, download_images
3333

3434
if TYPE_CHECKING:
3535
from homeassistant.core import HomeAssistant
@@ -118,10 +118,11 @@ async def on_hass_stop(event: Event) -> None: # noqa: ARG001
118118
raise ConfigEntryNotReady(exc) from err
119119

120120
# store the listen task and mass client in the entry data
121-
actions = await setup_controller_and_actions(hass, mass)
121+
actions = await setup_controller_and_actions(hass, mass, entry)
122122
register_actions(hass)
123123
entry.runtime_data = MusicAssistantQueueEntryData(mass, actions, listen_task)
124124
websocket_api.async_register_command(hass, download_images)
125+
websocket_api.async_register_command(hass, api_download_and_encode_image)
125126

126127
# If the listen task is already failed, we need to raise ConfigEntryNotReady
127128
if listen_task.done() and (listen_error := listen_task.exception()) is not None:

custom_components/mass_queue/actions.py

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
from typing import TYPE_CHECKING
66

7-
from homeassistant.config_entries import ConfigEntryState
7+
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
88
from homeassistant.core import (
99
HomeAssistant,
1010
ServiceCall,
@@ -26,6 +26,7 @@
2626
ATTR_LIMIT,
2727
ATTR_LIMIT_AFTER,
2828
ATTR_LIMIT_BEFORE,
29+
ATTR_LOCAL_IMAGE_ENCODED,
2930
ATTR_MEDIA_ALBUM_NAME,
3031
ATTR_MEDIA_ARTIST,
3132
ATTR_MEDIA_CONTENT_ID,
@@ -36,9 +37,11 @@
3637
ATTR_PROVIDERS,
3738
ATTR_QUEUE_ID,
3839
ATTR_QUEUE_ITEM_ID,
40+
CONF_DOWNLOAD_LOCAL,
3941
DEFAULT_QUEUE_ITEMS_LIMIT,
4042
DEFAULT_QUEUE_ITEMS_OFFSET,
4143
DOMAIN,
44+
LOGGER,
4245
SERVICE_GET_QUEUE_ITEMS,
4346
SERVICE_GET_RECOMMENDATIONS,
4447
SERVICE_MOVE_QUEUE_ITEM_DOWN,
@@ -60,7 +63,9 @@
6063
REMOVE_QUEUE_ITEM_SERVICE_SCHEMA,
6164
SEND_COMMAND_SERVICE_SCHEMA,
6265
)
63-
from .utils import find_image
66+
from .utils import (
67+
find_image,
68+
)
6469

6570
if TYPE_CHECKING:
6671
from music_assistant_client import MusicAssistantClient
@@ -71,11 +76,18 @@
7176
class MassQueueActions:
7277
"""Class to manage Music Assistant actions without passing `hass` and `mass_client` each time."""
7378

74-
def __init__(self, hass: HomeAssistant, mass_client: MusicAssistantClient):
79+
def __init__(
80+
self,
81+
hass: HomeAssistant,
82+
mass_client: MusicAssistantClient,
83+
config_entry: ConfigEntry,
84+
):
7585
"""Initialize class."""
7686
self._hass: HomeAssistant = hass
7787
self._client: MusicAssistantClient = mass_client
78-
self._controller = MassQueueController(self._hass, self._client)
88+
self._controller = MassQueueController(self._hass, self._client, config_entry)
89+
self._config_entry = config_entry
90+
self._download_local = config_entry.options.get(CONF_DOWNLOAD_LOCAL)
7991

8092
def setup_controller(self):
8193
"""Setup Music Assistant controller."""
@@ -161,9 +173,9 @@ async def get_active_queue(self, entity_id: str):
161173
queue_id = self.get_queue_id(entity_id)
162174
return await self._client.player_queues.get_active_queue(queue_id)
163175

164-
def _format_queue_item(self, queue_item: dict) -> dict:
176+
async def _format_queue_item(self, queue_item: dict) -> dict:
165177
"""Format list of queue items for response."""
166-
queue_item = queue_item.to_dict()
178+
LOGGER.debug(f"Got queue item with keys {queue_item.keys()}")
167179
media = queue_item["media_item"]
168180

169181
queue_item_id = queue_item["queue_item_id"]
@@ -172,6 +184,7 @@ def _format_queue_item(self, queue_item: dict) -> dict:
172184
media_album_name = "" if media_album is None else media_album.get("name", "")
173185
media_content_id = media["uri"]
174186
media_image = find_image(queue_item) or ""
187+
local_image_encoded = queue_item.get(ATTR_LOCAL_IMAGE_ENCODED)
175188
favorite = media["favorite"]
176189

177190
artists = media["artists"]
@@ -188,6 +201,9 @@ def _format_queue_item(self, queue_item: dict) -> dict:
188201
ATTR_FAVORITE: favorite,
189202
},
190203
)
204+
if local_image_encoded:
205+
response[ATTR_LOCAL_IMAGE_ENCODED] = local_image_encoded
206+
LOGGER.debug(f"Sending back response with keys {response.keys()}")
191207
return response
192208

193209
async def send_command(self, call: ServiceCall) -> ServiceResponse:
@@ -224,7 +240,7 @@ async def get_queue_items(self, call: ServiceCall) -> ServiceResponse:
224240
offset = max(offset, 0)
225241
queue_items = await self._controller.player_queue(queue_id, limit, offset)
226242
response: ServiceResponse = {
227-
entity_id: [self._format_queue_item(item) for item in queue_items],
243+
entity_id: [await self._format_queue_item(item) for item in queue_items],
228244
}
229245
return response
230246

@@ -325,8 +341,9 @@ def _get_music_assistant_client(
325341
async def setup_controller_and_actions(
326342
hass: HomeAssistant,
327343
mass_client: MusicAssistantClient,
344+
entry: ConfigEntry,
328345
) -> MassQueueActions:
329346
"""Initialize client and actions class, add actions to Home Assistant."""
330-
actions = MassQueueActions(hass, mass_client)
347+
actions = MassQueueActions(hass, mass_client, entry)
331348
actions.setup_controller()
332349
return actions

custom_components/mass_queue/config_flow.py

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,15 @@
55
from typing import TYPE_CHECKING, Any
66

77
import voluptuous as vol
8-
from homeassistant.config_entries import SOURCE_IGNORE, ConfigFlow, ConfigFlowResult
8+
from homeassistant.config_entries import (
9+
SOURCE_IGNORE,
10+
ConfigEntry,
11+
ConfigFlow,
12+
ConfigFlowResult,
13+
OptionsFlowWithReload,
14+
)
915
from homeassistant.const import CONF_URL
16+
from homeassistant.core import callback
1017
from homeassistant.helpers import aiohttp_client
1118
from music_assistant_client import MusicAssistantClient
1219
from music_assistant_client.exceptions import (
@@ -16,7 +23,11 @@
1623
)
1724
from music_assistant_models.api import ServerInfoMessage
1825

19-
from .const import DOMAIN, LOGGER
26+
from .const import (
27+
CONF_DOWNLOAD_LOCAL,
28+
DOMAIN,
29+
LOGGER,
30+
)
2031

2132
if TYPE_CHECKING:
2233
from homeassistant.core import HomeAssistant
@@ -25,6 +36,7 @@
2536

2637
DEFAULT_URL = "http://mass.local:8095"
2738
DEFAULT_TITLE = "Music Assistant Queue Items"
39+
DEFAULT_DOWNLOAD_LOCAL = False
2840

2941

3042
def get_manual_schema(user_input: dict[str, Any]) -> vol.Schema:
@@ -90,6 +102,7 @@ async def async_step_user(
90102
data={
91103
CONF_URL: user_input[CONF_URL],
92104
},
105+
options={CONF_DOWNLOAD_LOCAL: DEFAULT_DOWNLOAD_LOCAL},
93106
)
94107

95108
return self.async_show_form(
@@ -164,9 +177,50 @@ async def async_step_discovery_confirm(
164177
data={
165178
CONF_URL: self.server_info.base_url,
166179
},
180+
options={CONF_DOWNLOAD_LOCAL: DEFAULT_DOWNLOAD_LOCAL},
167181
)
168182
self._set_confirm_only()
169183
return self.async_show_form(
170184
step_id="discovery_confirm",
171185
description_placeholders={"url": self.server_info.base_url},
172186
)
187+
188+
@staticmethod
189+
@callback
190+
def async_get_options_flow(
191+
config_entry: ConfigEntry,
192+
) -> OptionsFlowHandler:
193+
"""Gets the options flow for this handler."""
194+
LOGGER.debug("Starting options flow.")
195+
return OptionsFlowHandler(config_entry)
196+
197+
198+
class OptionsFlowHandler(OptionsFlowWithReload):
199+
"""Options config flow for Music Assistant Queue Actions."""
200+
201+
def __init__(self, config_entry: ConfigEntry) -> None:
202+
"""Initialize options handler."""
203+
self._config_entry = config_entry
204+
self._download_local = config_entry.options.get(
205+
CONF_DOWNLOAD_LOCAL,
206+
DEFAULT_DOWNLOAD_LOCAL,
207+
)
208+
209+
async def async_step_init(self, user_input=None) -> ConfigFlowResult:
210+
"""Manage options."""
211+
if user_input is not None:
212+
LOGGER.debug("User input is not none, submitting data")
213+
entry = self.async_create_entry(data=self.config_entry.options | user_input)
214+
LOGGER.debug(f"Created entry {entry} ({dir(entry)})")
215+
return entry
216+
LOGGER.debug("User input is none, showing form...")
217+
default_download = self._download_local
218+
data_schema = vol.Schema(
219+
{
220+
vol.Required(CONF_DOWNLOAD_LOCAL, default=default_download): bool,
221+
},
222+
)
223+
return self.async_show_form(
224+
step_id="init",
225+
data_schema=data_schema,
226+
)

custom_components/mass_queue/const.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
SERVICE_UNFAVORITE_CURRENT_ITEM = "unfavorite_current_item"
1616
ATTR_QUEUE_ID = "active_queue"
1717
ATTR_CONFIG_ENTRY_ID = "config_entry_id"
18+
ATTR_LOCAL_IMAGE_ENCODED = "local_image_encoded"
1819
ATTR_LIMIT = "limit"
1920
ATTR_LIMIT_AFTER = "limit_after"
2021
ATTR_LIMIT_BEFORE = "limit_before"
@@ -31,6 +32,9 @@
3132
ATTR_DATA = "data"
3233
ATTR_FAVORITE = "favorite"
3334
ATTR_PROVIDERS = "providers"
35+
36+
CONF_DOWNLOAD_LOCAL = "download_local"
37+
3438
LOGGER = logging.getLogger(__package__)
3539

3640
DEFAULT_QUEUE_ITEMS_LIMIT = 500

0 commit comments

Comments
 (0)