Added Alexa Music

This commit is contained in:
2026-07-17 10:12:15 -04:00
parent 92c5268dc8
commit 28a8cb98f6
757 changed files with 151171 additions and 85450 deletions
@@ -0,0 +1,270 @@
"""My Music Library — Home Assistant Integration."""
from __future__ import annotations
import logging
import os
from typing import Any
import voluptuous as vol
from homeassistant.components.http import StaticPathConfig
from homeassistant.helpers.storage import Store
from homeassistant.components.websocket_api import (
ActiveConnection,
async_register_command,
websocket_command,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.loader import async_get_integration
from .api import ImageProxyView, MAQueueView, MusicAssistantBrowseView, MusicAssistantLibraryView, MusicAssistantProvidersView, MusicAssistantRecommendationsView, MusicAssistantSearchView, MusicAssistantSubitemsView, PlayerGroupView, PlayerQueueJumpView, PlayerQueueView
from .const import CARD_JS_FILENAME, CARD_URL, CONF_DEBUG_MODE, CONF_EXCLUDED_PLAYERS, CONF_MA_URL, DOMAIN, ICON_URL, MUSIC_ASSISTANT_DOMAIN, WS_CONFIG_COMMAND
_LOGGER = logging.getLogger(__name__)
PLATFORMS: list[str] = []
WWW_DIR = os.path.join(os.path.dirname(__file__), "www")
ICON_PATH = os.path.join(os.path.dirname(__file__), "brand", "icon.png")
_QUEUE_STORE_KEY = f"{DOMAIN}_queues"
_QUEUE_STORE_VERSION = 1
_INTEGRATION_LOGGER = logging.getLogger("custom_components.my_music_library")
def _apply_debug_mode(debug: bool) -> None:
"""Set the integration logger level based on the debug_mode option."""
_INTEGRATION_LOGGER.setLevel(logging.DEBUG if debug else logging.WARNING)
_INTEGRATION_LOGGER.debug("Debug mode %s", "enabled" if debug else "disabled")
async def async_setup(hass: HomeAssistant, config: dict) -> bool:
"""Set up the My Music Library component."""
hass.data.setdefault(DOMAIN, {})
# Load persisted per-player queues from disk
store = Store(hass, _QUEUE_STORE_VERSION, _QUEUE_STORE_KEY)
stored = await store.async_load() or {}
hass.data[DOMAIN]["queue_store"] = store
hass.data[DOMAIN]["queues"] = stored.get("queues", {})
hass.data[DOMAIN]["groups"] = stored.get("groups", {})
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up My Music Library from a config entry."""
hass.data.setdefault(DOMAIN, {})
# Serve the card JS file from /my_music_library/<filename>
card_js_path = os.path.join(WWW_DIR, CARD_JS_FILENAME)
if not os.path.isfile(card_js_path):
_LOGGER.error("Card JS file not found: %s", card_js_path)
raise ConfigEntryNotReady(f"Missing frontend file: {card_js_path}")
# Guard against double-registration (HA may call setup_entry on reload/restart)
registered_paths: set[str] = hass.data[DOMAIN].setdefault("_registered_paths", set())
static_registrations: list[StaticPathConfig] = []
if CARD_URL not in registered_paths:
static_registrations.append(StaticPathConfig(CARD_URL, card_js_path, cache_headers=False))
registered_paths.add(CARD_URL)
_LOGGER.debug("Registered static path %s -> %s", CARD_URL, card_js_path)
else:
_LOGGER.debug("Static path already registered, skipping: %s", CARD_URL)
if ICON_URL not in registered_paths and os.path.isfile(ICON_PATH):
static_registrations.append(StaticPathConfig(ICON_URL, ICON_PATH, cache_headers=False))
registered_paths.add(ICON_URL)
_LOGGER.debug("Registered icon static path %s -> %s", ICON_URL, ICON_PATH)
if static_registrations:
await hass.http.async_register_static_paths(static_registrations)
# Build a versioned URL for reliable browser cache-busting, same principle as
# HACS's ?hacstag= parameter.
#
# We deliberately do NOT use add_extra_js_url: that mechanism loads the module
# independently of the Lovelace resource, and HA's scoped-custom-element-registry
# polyfill causes customElements.define to be called twice even when both paths
# use the same URL — triggering "already been used with this registry" errors.
# The Lovelace resource mechanism is the standard approach for custom cards and
# is sufficient (lovelace is a hard dependency so registration is guaranteed).
integration = await async_get_integration(hass, DOMAIN)
version = integration.manifest.get("version", "0")
versioned_card_url = f"{CARD_URL}?v={version}"
_LOGGER.debug("Setting up My Music Library v%s", version)
await _async_register_lovelace_resource(hass, versioned_card_url, CARD_URL)
# Register HTTP proxy views (search + library + subitems → MA server)
hass.http.register_view(MusicAssistantSearchView)
hass.http.register_view(MusicAssistantLibraryView)
hass.http.register_view(MusicAssistantSubitemsView)
hass.http.register_view(PlayerQueueView)
hass.http.register_view(PlayerQueueJumpView)
hass.http.register_view(MAQueueView)
hass.http.register_view(PlayerGroupView)
hass.http.register_view(MusicAssistantBrowseView)
hass.http.register_view(MusicAssistantRecommendationsView)
hass.http.register_view(MusicAssistantProvidersView)
hass.http.register_view(ImageProxyView)
# Register WebSocket command so the card can fetch its config
_register_websocket_commands(hass)
hass.data[DOMAIN][entry.entry_id] = {"entry": entry}
_apply_debug_mode(entry.options.get(CONF_DEBUG_MODE, False))
entry.async_on_unload(entry.add_update_listener(_async_options_updated))
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def _async_options_updated(
hass: HomeAssistant, entry: ConfigEntry
) -> None:
"""React to options changes (debug toggle, excluded players, etc.)."""
_apply_debug_mode(entry.options.get(CONF_DEBUG_MODE, False))
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id, None)
return unload_ok
def _register_websocket_commands(hass: HomeAssistant) -> None:
"""Register WebSocket commands exposed to the frontend card."""
@websocket_command({vol.Required("type"): WS_CONFIG_COMMAND})
def ws_get_config(
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
) -> None:
"""Return the integration config to the card."""
entries = hass.config_entries.async_entries(DOMAIN)
if not entries:
connection.send_result(msg["id"], {"ma_url": None})
return
entry = entries[0]
# Find the Music Assistant config entry so the card can call MA's WS commands
ma_entries = hass.config_entries.async_entries(MUSIC_ASSISTANT_DOMAIN)
ma_entry_id = ma_entries[0].entry_id if ma_entries else None
connection.send_result(
msg["id"],
{
"ma_url": entry.data.get(CONF_MA_URL) or None,
"ma_entry_id": ma_entry_id,
"default_player": entry.data.get("default_player") or None,
"default_tab": entry.data.get("default_tab", "player"),
"excluded_players": list(entry.options.get(CONF_EXCLUDED_PLAYERS, [])),
"debug_mode": bool(entry.options.get(CONF_DEBUG_MODE, False)),
},
)
async_register_command(hass, ws_get_config)
_LOGGER.debug("Registered WebSocket command: %s", WS_CONFIG_COMMAND)
async def _async_register_lovelace_resource(
hass: HomeAssistant, url: str, base_url: str
) -> None:
"""Add the card JS as a Lovelace resource (for Cast / companion app support).
``url`` — the versioned URL to register (e.g. /my_music_library/card.js?v=3.1.2)
``base_url`` — the fixed base path without query params (e.g. /my_music_library/card.js)
Strategy — delete-then-add, never add-then-delete:
1. Collect every existing Lovelace resource whose URL starts with ``base_url``
(this matches the exact current URL, any previous versioned URL, and the
plain unversioned URL used by 3.1.1).
2. If the only existing entry is already the target ``url``, do nothing.
3. Otherwise delete ALL collected entries first, then add the new ``url``.
Deleting before adding ensures the browser never sees two different module
versions in Lovelace storage at the same time, which would cause
customElements.define to be called twice → "configuration error".
"""
try:
lovelace = hass.data.get("lovelace")
if lovelace is None:
return
if hasattr(lovelace, "resources"):
resources = lovelace.resources
elif isinstance(lovelace, dict):
resources = lovelace.get("resources")
else:
return
if resources is None:
return
await resources.async_load()
# Collect all existing entries that belong to this card.
existing: list[tuple[str, str]] = [] # (item_id, r_url)
for r in resources.async_items():
r_url = r.get("url", "") if isinstance(r, dict) else getattr(r, "url", "")
if r_url == base_url or r_url.startswith(base_url + "?"):
item_id = r.get("id") if isinstance(r, dict) else getattr(r, "id", None)
if item_id:
existing.append((item_id, r_url))
# Already perfectly registered — nothing to do.
if len(existing) == 1 and existing[0][1] == url:
_LOGGER.debug("Lovelace resource already registered: %s", url)
return
delete_fn = getattr(resources, "async_delete_item", None)
create_fn = getattr(resources, "async_create_item", None)
# If we have stale entries but cannot delete them, bail out entirely.
# Adding the new URL alongside a stale one would make the browser load
# two different module versions → customElements.define conflict → error.
if existing and not callable(delete_fn):
_LOGGER.debug(
"Cannot clean up stale Lovelace resource(s) — skipping registration"
)
return
# Delete ALL stale entries first.
for item_id, old_url in existing:
try:
await delete_fn(item_id)
_LOGGER.info("Removed old Lovelace resource %s (id=%s)", old_url, item_id)
except Exception: # noqa: BLE001
# A deletion failed: abort to avoid a stale + new entry coexisting.
_LOGGER.warning(
"Failed to remove Lovelace resource id=%s — aborting registration",
item_id,
)
return
# Add the new versioned entry.
if callable(create_fn):
await create_fn({"res_type": "module", "url": url})
_LOGGER.info("Lovelace resource registered: %s", url)
else:
# Fallback for very old HA builds without async_create_item.
# Only reached when existing is empty (otherwise we returned above),
# so there is no stale entry to collide with.
data = getattr(resources, "data", None)
if isinstance(data, list):
if not any(
(r.get("url") if isinstance(r, dict) else getattr(r, "url", "")) == url
for r in data
):
data.append({"type": "module", "url": url})
except Exception: # noqa: BLE001
_LOGGER.debug("Lovelace resource registration skipped for %s (non-critical)", url)
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

@@ -0,0 +1,177 @@
"""Config flow for My Music Library."""
from __future__ import annotations
from typing import Any
from urllib.parse import urlparse
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult, OptionsFlow
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.selector import (
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
)
from .const import (
CONF_DEBUG_MODE,
CONF_DEFAULT_PLAYER,
CONF_DEFAULT_TAB,
CONF_EXCLUDED_PLAYERS,
CONF_MA_URL,
DEFAULT_MA_URL,
DEFAULT_TAB,
DOMAIN,
MUSIC_ASSISTANT_DOMAIN,
NAME,
)
def _get_ma_players(hass: HomeAssistant) -> dict[str, str]:
"""Return a dict of {entity_id: friendly_name} for Music Assistant media_players."""
ent_reg = er.async_get(hass)
players: dict[str, str] = {}
for entity in ent_reg.entities.values():
if (
entity.platform == MUSIC_ASSISTANT_DOMAIN
and entity.domain == "media_player"
and not entity.disabled
):
state = hass.states.get(entity.entity_id)
name = (
state.attributes.get("friendly_name", entity.entity_id)
if state
else entity.entity_id
)
players[entity.entity_id] = name
return players
def _get_all_players(hass: HomeAssistant) -> dict[str, str]:
"""Return a dict of {entity_id: friendly_name} for all non-unavailable media_player entities."""
players: dict[str, str] = {}
for state in hass.states.async_all("media_player"):
if state.state != "unavailable":
name = state.attributes.get("friendly_name", state.entity_id)
players[state.entity_id] = name
return dict(sorted(players.items(), key=lambda x: x[1].lower()))
def _validate_url(url: str) -> str | None:
"""Return None if valid, or an error key if invalid."""
if not url:
return None # empty = not configured, that's fine
try:
parsed = urlparse(url)
if parsed.scheme not in ("http", "https") or not parsed.netloc:
return "invalid_url"
except Exception: # noqa: BLE001
return "invalid_url"
return None
class MyMusicLibraryConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for My Music Library."""
VERSION = 1
@staticmethod
@callback
def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow:
"""Return the options flow handler."""
return MyMusicLibraryOptionsFlow()
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
await self.async_set_unique_id(DOMAIN)
self._abort_if_unique_id_configured()
errors: dict[str, str] = {}
ma_players = _get_ma_players(self.hass)
if user_input is not None:
url_error = _validate_url(user_input.get(CONF_MA_URL, ""))
if url_error:
errors[CONF_MA_URL] = url_error
else:
return self.async_create_entry(title=NAME, data=user_input)
player_options = {
"": "Auto-detect (first available player)",
**ma_players,
}
schema = vol.Schema(
{
vol.Optional(CONF_MA_URL, default=DEFAULT_MA_URL): str,
vol.Optional(CONF_DEFAULT_PLAYER, default=""): vol.In(player_options),
vol.Optional(CONF_DEFAULT_TAB, default=DEFAULT_TAB): vol.In(
{
"player": "Player",
"search": "Search",
"library": "Library",
}
),
}
)
description_placeholders: dict[str, str] = {
"ma_url_hint": "e.g. http://homeassistant.local:8095",
}
if not ma_players:
description_placeholders["ma_warning"] = (
"Music Assistant integration not found — "
"install it first for full functionality."
)
return self.async_show_form(
step_id="user",
data_schema=schema,
errors=errors,
description_placeholders=description_placeholders,
)
class MyMusicLibraryOptionsFlow(OptionsFlow):
"""Handle options for My Music Library (player exclusion, etc.)."""
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Manage the options."""
if user_input is not None:
return self.async_create_entry(data=user_input)
all_players = _get_all_players(self.hass)
current_excluded: list[str] = list(
self.config_entry.options.get(CONF_EXCLUDED_PLAYERS, [])
)
# Keep wildcard patterns as-is; drop stale exact entity IDs only.
current_excluded = [
p for p in current_excluded
if "*" in p or p in all_players
]
current_debug: bool = bool(
self.config_entry.options.get(CONF_DEBUG_MODE, False)
)
schema = vol.Schema(
{
vol.Optional(CONF_EXCLUDED_PLAYERS, default=current_excluded): SelectSelector(
SelectSelectorConfig(
options=[{"value": k, "label": v} for k, v in all_players.items()],
multiple=True,
mode=SelectSelectorMode.LIST,
custom_value=True,
)
),
vol.Optional(CONF_DEBUG_MODE, default=current_debug): bool,
}
)
return self.async_show_form(step_id="init", data_schema=schema)
@@ -0,0 +1,23 @@
"""Constants for My Music Library."""
from __future__ import annotations
DOMAIN = "my_music_library"
NAME = "My Music Library"
CONF_DEFAULT_PLAYER = "default_player"
CONF_DEFAULT_TAB = "default_tab"
CONF_MA_URL = "ma_url"
CONF_DEBUG_MODE = "debug_mode"
CONF_EXCLUDED_PLAYERS = "excluded_players"
DEFAULT_TAB = "player"
DEFAULT_MA_URL = "http://homeassistant.local:8095"
MUSIC_ASSISTANT_DOMAIN = "mass"
CARD_JS_FILENAME = "my-music-library-card.js"
CARD_URL = f"/my_music_library/{CARD_JS_FILENAME}"
ICON_URL = f"/{DOMAIN}/icon.png"
# WebSocket command exposed to the frontend card
WS_CONFIG_COMMAND = f"{DOMAIN}/config"
@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
<!-- Black rounded background -->
<rect width="256" height="256" rx="40" fill="#111111"/>
<!-- Double croche (beamed sixteenth notes) in green -->
<g fill="#22c55e">
<!-- Note head 1 (left) -->
<ellipse cx="89" cy="173" rx="25" ry="18" transform="rotate(-15 89 173)"/>
<!-- Note head 2 (right) -->
<ellipse cx="166" cy="155" rx="25" ry="18" transform="rotate(-15 166 155)"/>
<!-- Stem 1 -->
<rect x="109" y="80" width="9" height="96"/>
<!-- Stem 2 -->
<rect x="186" y="62" width="9" height="96"/>
<!-- Beam 1 (upper) -->
<polygon points="109,80 195,62 195,76 109,94"/>
<!-- Beam 2 (lower — makes it a double croche) -->
<polygon points="109,101 195,83 195,97 109,115"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 805 B

@@ -0,0 +1,13 @@
{
"domain": "my_music_library",
"name": "My Music Library",
"after_dependencies": ["mass"],
"codeowners": ["@Patafoin"],
"config_flow": true,
"dependencies": ["frontend", "http", "lovelace"],
"documentation": "https://github.com/Patafoin/ha-my-music-library",
"iot_class": "local_push",
"issue_tracker": "https://github.com/Patafoin/ha-my-music-library/issues",
"requirements": [],
"version": "3.10.4"
}
@@ -0,0 +1,39 @@
{
"config": {
"step": {
"user": {
"title": "Set up My Music Library",
"description": "Enter the URL of your Music Assistant server ({ma_url_hint}). Required for search and library browsing.",
"data": {
"ma_url": "Music Assistant server URL",
"default_player": "Default player device",
"default_tab": "Default tab on open"
}
}
},
"error": {
"cannot_connect": "Failed to connect",
"invalid_url": "Invalid URL — must start with http:// or https://",
"unknown": "Unexpected error"
},
"abort": {
"already_configured": "My Music Library is already configured",
"single_instance_allowed": "Only a single instance is allowed"
}
},
"options": {
"step": {
"init": {
"title": "Player settings",
"description": "Select players to hide from the device picker. You can also type a wildcard pattern (e.g. `media_player.browser_mod_*`) and press Enter to exclude all matching players.",
"data": {
"excluded_players": "Hidden players",
"debug_mode": "Enable debug logging"
},
"data_description": {
"debug_mode": "When enabled, detailed logs appear in HA logs (filter: my_music_library) and in the browser console (F12). Disable after debugging."
}
}
}
}
}
@@ -0,0 +1,39 @@
{
"config": {
"step": {
"user": {
"title": "My Music Library einrichten",
"description": "Geben Sie die URL Ihres Music Assistant Servers ein ({ma_url_hint}). Erforderlich für Suche und Bibliotheksdurchsuchung.",
"data": {
"ma_url": "URL des Music Assistant Servers",
"default_player": "Standard-Player",
"default_tab": "Standardreiter beim Öffnen"
}
}
},
"error": {
"cannot_connect": "Verbindung fehlgeschlagen",
"invalid_url": "Ungültige URL — muss mit http:// oder https:// beginnen",
"unknown": "Unerwarteter Fehler"
},
"abort": {
"already_configured": "My Music Library ist bereits konfiguriert",
"single_instance_allowed": "Nur eine Instanz ist erlaubt"
}
},
"options": {
"step": {
"init": {
"title": "Player-Einstellungen",
"description": "Wählen Sie Player aus, die in der Geräteauswahl ausgeblendet werden sollen. Sie können auch ein Wildcard-Muster eingeben (z.B. `media_player.browser_mod_*`) und Enter drücken, um alle passenden Player auszuschließen.",
"data": {
"excluded_players": "Ausgeblendete Player",
"debug_mode": "Debug-Protokollierung aktivieren"
},
"data_description": {
"debug_mode": "Wenn aktiviert, erscheinen detaillierte Protokolle in den HA-Logs (Filter: my_music_library) und in der Browser-Konsole (F12). Nach dem Debugging deaktivieren."
}
}
}
}
}
@@ -0,0 +1,39 @@
{
"config": {
"step": {
"user": {
"title": "Set up My Music Library",
"description": "Enter the URL of your Music Assistant server ({ma_url_hint}). Required for search and library browsing.",
"data": {
"ma_url": "Music Assistant server URL",
"default_player": "Default player device",
"default_tab": "Default tab on open"
}
}
},
"error": {
"cannot_connect": "Failed to connect",
"invalid_url": "Invalid URL — must start with http:// or https://",
"unknown": "Unexpected error"
},
"abort": {
"already_configured": "My Music Library is already configured",
"single_instance_allowed": "Only a single instance is allowed"
}
},
"options": {
"step": {
"init": {
"title": "Player settings",
"description": "Select players to hide from the device picker. You can also type a wildcard pattern (e.g. `media_player.browser_mod_*`) and press Enter to exclude all matching players.",
"data": {
"excluded_players": "Hidden players",
"debug_mode": "Enable debug logging"
},
"data_description": {
"debug_mode": "When enabled, detailed logs appear in HA logs (filter: my_music_library) and in the browser console (F12). Disable after debugging."
}
}
}
}
}
@@ -0,0 +1,39 @@
{
"config": {
"step": {
"user": {
"title": "Configurer My Music Library",
"description": "Entrez l'URL de votre serveur Music Assistant ({ma_url_hint}). Requis pour la recherche et la navigation dans la bibliothèque.",
"data": {
"ma_url": "URL du serveur Music Assistant",
"default_player": "Lecteur par défaut",
"default_tab": "Onglet affiché à l'ouverture"
}
}
},
"error": {
"cannot_connect": "Connexion impossible",
"invalid_url": "URL invalide — doit commencer par http:// ou https://",
"unknown": "Erreur inattendue"
},
"abort": {
"already_configured": "My Music Library est déjà configuré",
"single_instance_allowed": "Une seule instance est autorisée"
}
},
"options": {
"step": {
"init": {
"title": "Paramètres des lecteurs",
"description": "Sélectionnez les lecteurs à masquer dans le sélecteur de périphérique. Vous pouvez aussi saisir un motif avec wildcard (ex. `media_player.browser_mod_*`) et appuyer sur Entrée pour exclure tous les lecteurs correspondants.",
"data": {
"excluded_players": "Lecteurs masqués",
"debug_mode": "Activer les logs de débogage"
},
"data_description": {
"debug_mode": "Quand activé, des logs détaillés apparaissent dans les journaux HA (filtre : my_music_library) et dans la console du navigateur (F12). Désactivez après le débogage."
}
}
}
}
}
File diff suppressed because it is too large Load Diff