"""HTTP API views for My Music Library (proxy to Music Assistant).""" from __future__ import annotations import asyncio import dataclasses import enum import logging from http import HTTPStatus from types import SimpleNamespace from typing import Any from urllib.parse import quote import aiohttp from aiohttp import web from homeassistant.components.http import HomeAssistantView from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from . import outputs from .const import CONF_MA_URL, DOMAIN from .entity import UNIQUE_ID_PREFIX _LOGGER = logging.getLogger(__name__) # ── Serialization ──────────────────────────────────────────────────────────── def _to_json_safe(obj: Any) -> Any: """Recursively convert MA model objects to JSON-serialisable dicts.""" if obj is None or isinstance(obj, (bool, int, float, str)): return obj if isinstance(obj, enum.Enum): return obj.value if dataclasses.is_dataclass(obj) and not isinstance(obj, type): d = {f.name: _to_json_safe(getattr(obj, f.name)) for f in dataclasses.fields(obj)} if "image" not in d: img = getattr(obj, "image", None) if img is not None: d["image"] = _to_json_safe(img) return d if isinstance(obj, dict): return {k: _to_json_safe(v) for k, v in obj.items()} if isinstance(obj, (list, tuple, set, frozenset)): return [_to_json_safe(i) for i in obj] return str(obj) def _extract_thumbnail(item: dict) -> str: """Extract thumbnail URL from a serialised MA item dict. Looks in multiple locations where MA stores image paths, returns the first non-empty path found. """ thumb = item.get("thumbnail") or "" if thumb: return thumb image = item.get("image") if isinstance(image, dict): thumb = image.get("path") or image.get("url") or "" elif isinstance(image, str) and image: thumb = image if thumb: return thumb metadata = item.get("metadata") or {} for img in metadata.get("images") or []: path = img.get("path", "") if isinstance(img, dict) else "" if path: return path return "" def _make_thumb_url(raw_path: str) -> str: """Wrap a raw image path in our thumbnail proxy endpoint. Instead of exposing raw image URLs (which may require provider auth or be unreachable from the browser), route everything through our server-side proxy that resolves via the MA server. """ if not raw_path: return "" return f"/my_music_library/thumb?path={quote(raw_path, safe='')}" def _serialize_search_results(results: Any) -> dict: """Convert a MA SearchResults object (or dict) to a plain dict.""" if results is None: return {"tracks": [], "artists": [], "albums": [], "playlists": []} safe = _to_json_safe(results) if isinstance(safe, dict): for key in ("tracks", "artists", "albums", "playlists", "radios"): for item in safe.get(key) or []: if isinstance(item, dict): raw = item.get("thumbnail") or _extract_thumbnail(item) item["thumbnail"] = _make_thumb_url(raw) return safe return {"tracks": [], "artists": [], "albums": [], "playlists": [], "raw": str(safe)} # ── MA client / URL resolution ──────────────────────────────────────────────── # # my_music_library owns its Music Assistant connection (see mass_connection.py) — # there is exactly one config entry, and it only reaches ConfigEntryState.LOADED # once that connection succeeded. No more scanning other integrations' domains. def _get_mass_client(hass: HomeAssistant) -> Any | None: """Return the native MusicAssistantClient for our config entry, if loaded.""" for entry in hass.config_entries.async_entries(DOMAIN): if entry.state == ConfigEntryState.LOADED: return entry.runtime_data.mass _LOGGER.warning( "My Music Library config entry is not loaded — check its connection " "to the Music Assistant server (filter: my_music_library)." ) return None def _get_mass_url(hass: HomeAssistant) -> str | None: """Return the configured Music Assistant server URL.""" for entry in hass.config_entries.async_entries(DOMAIN): if url := entry.data.get(CONF_MA_URL): return url.rstrip("/") return None # ── Search ──────────────────────────────────────────────────────────────────── async def _search_via_ma_client( hass: HomeAssistant, query: str, limit: int, *, library_only: bool = False, ) -> dict | None: """Search Music Assistant via the native client.""" mass = _get_mass_client(hass) if mass is None: return None try: results = await mass.music.search( search_query=query, limit=limit, library_only=library_only ) return _serialize_search_results(results) except Exception as err: # noqa: BLE001 _LOGGER.warning("MA search(%r) failed: %s", query, err) return None # ── Library ─────────────────────────────────────────────────────────────────── def _normalize_library_item(item: dict) -> dict: """Map MA Python client fields to the browse_media-compatible fields the card expects.""" title = item.get("name") or item.get("title") or "" uri = item.get("uri") or item.get("media_content_id") or "" media_type = item.get("media_type", "") if isinstance(media_type, dict): media_type = media_type.get("value", "") thumbnail = _make_thumb_url(_extract_thumbnail(item)) artist = item.get("media_artist") or "" if not artist: artists = item.get("artists") or [] if artists and isinstance(artists[0], dict): artist = artists[0].get("name", "") album_type = item.get("album_type", "album") if isinstance(album_type, dict): album_type = album_type.get("value", "album") track_number = item.get("track_number") or item.get("position") or 0 duration = item.get("duration") or 0 provider_mappings = item.get("provider_mappings") or [] providers: set[str] = set() provider_instances: set[str] = set() for m in provider_mappings: if isinstance(m, dict): dom = m.get("provider_domain", "") inst = ( m.get("provider_instance_id_or_domain") or m.get("provider_instance") or m.get("provider_instance_id") or dom ) if dom: providers.add(dom) if inst: provider_instances.add(inst) elif isinstance(m, str): provider_instances.add(m) else: dom = str(getattr(m, "provider_domain", "") or "") inst = str( getattr(m, "provider_instance", "") or getattr(m, "provider_instance_id_or_domain", "") or dom ) if dom: providers.add(dom) if inst: provider_instances.add(inst) provider_instances.discard("") provider_instances.discard("builtin") providers.discard("") providers.discard("builtin") return { "title": title, "media_content_id": uri, "media_content_type": media_type, "thumbnail": thumbnail, "media_artist": artist, "album_type": str(album_type).lower() if album_type else "album", "track_number": int(track_number) if track_number else 0, "duration": float(duration) if duration else 0, "providers": sorted(providers), "provider_instances": sorted(provider_instances), } _LIBRARY_METHODS: dict[str, str] = { "artists": "get_library_artists", "albums": "get_library_albums", "tracks": "get_library_tracks", "playlists": "get_library_playlists", "radios": "get_library_radios", } async def _get_library_via_ma_client( hass: HomeAssistant, media_type: str, limit: int, favorite: bool, offset: int = 0, provider_instance: str | None = None, ) -> list | None: """Fetch library items via the native MA client.""" mass = _get_mass_client(hass) if mass is None: return None method_name = _LIBRARY_METHODS.get(media_type) if method_name is None: return None fn = getattr(mass.music, method_name) try: items = await fn(favorite=favorite, limit=limit, offset=offset, provider=provider_instance) except Exception as err: # noqa: BLE001 _LOGGER.warning("Library fetch for %s failed: %s", media_type, err) return None normalized = [_normalize_library_item(_to_json_safe(i)) for i in items[:limit]] _LOGGER.debug("Library %s: %d items", media_type, len(normalized)) return normalized # ── Recommendations ────────────────────────────────────────────────────────── def _normalize_recommendation_item(item: dict) -> dict: """Normalize a single item from a MA recommendation folder.""" media_type = item.get("media_type", "") if isinstance(media_type, dict): media_type = media_type.get("value", "") media_type = str(media_type).lower() if media_type else "" if media_type in ("track", "album", "artist", "playlist", "radio"): return _normalize_library_item(item) return _normalize_browse_item(item) async def _get_recommendation_folder_items( mass: Any, provider: str, item_id: str, ) -> list[Any]: """Fetch the concrete items of one recommendation folder. ``music.recommendations()`` only returns the folder metadata (name, provider, item_id) — MA's API works in two steps, and the actual content of a folder requires a second call to "music/recommendations/items" (server-side: RecommendationsController.get_recommendation_items). There is no Python-client wrapper for it yet, so we call it directly via ``send_command``. """ result = await asyncio.wait_for( mass.send_command("music/recommendations/items", provider=provider, item_id=item_id), timeout=10, ) return list(result) if not isinstance(result, list) else result async def _get_recommendations_via_ma_client( hass: HomeAssistant, ) -> list[dict] | None: """Fetch recommendations via the native MA client.""" mass = _get_mass_client(hass) if mass is None: return None try: folders = await asyncio.wait_for(mass.music.recommendations(), timeout=10) except asyncio.TimeoutError: _LOGGER.warning("Recommendations fetch timed out") return None except Exception as err: # noqa: BLE001 _LOGGER.warning("Recommendations fetch failed: %s", err) return None _LOGGER.debug("Recommendations: %d folders", len(folders)) safe_folders = [_to_json_safe(folder) for folder in folders] # A folder from music.recommendations() only carries metadata — # its actual content needs one extra call per folder. Fetch them # all in parallel, isolating failures so one slow/broken tiroir # doesn't blank the others. fetch_tasks = [ _get_recommendation_folder_items( mass, str(f.get("provider") or ""), str(f.get("item_id") or ""), ) for f in safe_folders ] items_results = await asyncio.gather(*fetch_tasks, return_exceptions=True) normalized: list[dict] = [] for f, items_result in zip(safe_folders, items_results): try: if isinstance(items_result, BaseException): _LOGGER.warning( "Recommendations: items fetch failed for folder %r (provider=%s, item_id=%s): %s", f.get("name"), f.get("provider"), f.get("item_id"), items_result, ) items_raw = f.get("items") or [] else: items_raw = items_result or f.get("items") or [] folder_domain = str(f.get("provider_domain") or f.get("provider", "") or "") folder_instance = str( f.get("provider_instance_id_or_domain") or f.get("provider_instance") or f.get("provider_instance_id") or folder_domain ) is_library_folder = folder_domain in ("library", "builtin", "") items_norm = [] for i in items_raw: i_safe = _to_json_safe(i) if is_library_folder: pm = i_safe.get("provider_mappings") or [] if pm: i_safe["provider_mappings"] = [ m for m in pm if (m.get("in_library") is True if isinstance(m, dict) else getattr(m, "in_library", True)) ] or pm item = _normalize_recommendation_item(i_safe) if not is_library_folder: if folder_instance and not item.get("provider_instances"): item["provider_instances"] = [folder_instance] if folder_domain and not item.get("providers"): item["providers"] = [folder_domain] items_norm.append(item) # Infer folder provider from items when MA doesn't provide it inferred_domain = folder_domain inferred_instance = folder_instance if is_library_folder and items_norm: instance_counts: dict[str, int] = {} for itm in items_norm: for pi in itm.get("provider_instances") or []: if pi and pi not in ("builtin", "library"): instance_counts[pi] = instance_counts.get(pi, 0) + 1 if instance_counts: top_inst = max(instance_counts, key=instance_counts.get) # type: ignore[arg-type] if instance_counts[top_inst] == len(items_norm): inferred_instance = top_inst inferred_domain = top_inst.split("--")[0] if "--" in top_inst else top_inst normalized.append({ "folder_id": f.get("item_id") or f.get("path") or "", "name": f.get("name") or f.get("label") or "", "icon": f.get("icon") or "", "provider_domain": inferred_domain, "provider_instance": inferred_instance, "items": items_norm, }) except Exception as folder_err: # noqa: BLE001 _LOGGER.warning( "Recommendations: skipping folder %r due to processing error: %s", f.get("name"), folder_err, ) continue return normalized # ── Browse ──────────────────────────────────────────────────────────────────── def _normalize_browse_item(item: dict) -> dict: """Normalise a MA browse result item (BrowseFolder or MediaItem) for the card.""" media_type = item.get("media_type", "") if isinstance(media_type, dict): media_type = media_type.get("value", "") media_type = str(media_type).lower() if media_type else "" is_folder = ( "path" in item and media_type in ("", "folder", "directory") ) or media_type in ("folder", "directory") title = ( item.get("name") or item.get("display_name") or item.get("label") or item.get("title") or "" ) if not title: title = item.get("translation_key") or item.get("item_id") or "" if title: title = title.replace("_", " ").capitalize() # For BrowseFolder items, prefer `path` (the navigation path MA expects) # over `uri` (an auto-generated semantic identifier). uri = item.get("path") or item.get("uri") or item.get("media_content_id") or "" # MA sometimes returns URIs like "provider://folder/Deftones" where "folder/" # is an internal prefix, not a real directory. Strip it so navigation works. if "://" in uri: _scheme, _path = uri.split("://", 1) if _path.startswith("folder/"): uri = f"{_scheme}://{_path[len('folder/'):]}" thumbnail = _make_thumb_url(_extract_thumbnail(item)) artist = item.get("media_artist") or "" if not artist: artists = item.get("artists") or [] if artists and isinstance(artists[0], dict): artist = artists[0].get("name", "") duration = item.get("duration") or 0 return { "title": title, "uri": uri, "media_content_type": media_type or ("folder" if is_folder else "music"), "thumbnail": thumbnail, "subtitle": artist, "duration": float(duration) if duration else 0, "is_folder": is_folder, } def _is_ma_back_item(item: dict) -> bool: """Return True for MA virtual 'back' navigation items.""" name = item.get("title", "") or item.get("name", "") if name == "..": return True uri = item.get("uri", "") if "://" in uri: tail = uri.split("://", 1)[1].lower().rstrip("/") if tail in ("back", "..", "root"): return True return False async def _browse_via_ma_client( hass: HomeAssistant, uri: str | None, limit: int = 200, ) -> list | None: """Browse a MA path via the native MA client.""" mass = _get_mass_client(hass) if mass is None: return None try: # Raw command, not mass.music.browse(): the client turns every browse # entry without provider_mappings (all folders) into an ItemMapping, # which drops BrowseFolder.path — the navigation path some providers # set explicitly (e.g. Deezer "…://Made For You" vs item_id # "made_for_me"). The raw dicts keep it. items = await mass.send_command("music/browse", path=uri) except Exception as err: # noqa: BLE001 _LOGGER.warning("browse(%r) failed: %s", uri, err) return None normalized = [] for raw_item in items: safe = _to_json_safe(raw_item) n = _normalize_browse_item(safe) if _is_ma_back_item(n): n["is_back"] = True elif n.get("is_folder") and uri: n["uri"] = _browse_folder_uri(safe, uri, n["uri"]) normalized.append(n) return normalized[:limit] def _browse_folder_uri(raw: dict, parent_uri: str, normalized_uri: str) -> str: """Return the navigation URI of a browse sub-folder. MA's `path` is the navigation path. When a provider sets it explicitly, use it as is: Deezer's "Made For You" folder has `path` "…://Made For You" but `item_id` "made_for_me", so rebuilding from `item_id` breaks it (502). When a provider doesn't set it, `BrowseFolder` defaults `path` to `://`, which loses the parent folder: only then rebuild it from the parent URI — without prepending the parent when `item_id` is already relative to the provider root, as for the filesystem provider ("Disco/ABBA Gold" must not become "Disco/Disco/ABBA Gold", issue #17). """ item_id = str(raw.get("item_id") or "") path = str(raw.get("path") or "") if path and path != f"{raw.get('provider') or ''}://{item_id}": return normalized_uri if not item_id or "://" not in parent_uri: return normalized_uri scheme, parent_sub = parent_uri.split("://", 1) parent_sub = parent_sub.rstrip("/") if not parent_sub or item_id == parent_sub or item_id.startswith(f"{parent_sub}/"): return f"{scheme}://{item_id}" return f"{scheme}://{parent_sub}/{item_id}" # ── Subitems (artist albums, album tracks, playlist tracks) ─────────────────── _SUBITEM_METHODS: dict[str, str] = { "artist_albums": "get_artist_albums", "album_tracks": "get_album_tracks", "playlist_tracks": "get_playlist_tracks", } def _parse_ma_uri(uri: str) -> tuple[str, str]: """Return (item_id, provider_domain) from a MA URI string. Examples -------- ``spotify://artist/12345`` → ("12345", "spotify") ``music_assistant://abc`` → ("abc", "music_assistant") ``qobuz://album/xyz/789`` → ("789", "qobuz") """ if "://" not in uri: return uri, "library" scheme, rest = uri.split("://", 1) parts = [p for p in rest.split("/") if p] item_id = parts[-1] if parts else rest return item_id, scheme async def _get_subitems( hass: HomeAssistant, action: str, uri: str, limit: int = 50, ) -> list | None: """Fetch sub-items of a MA library item via the native MA client.""" mass = _get_mass_client(hass) if mass is None: return None method_name = _SUBITEM_METHODS.get(action) if method_name is None: return None fn = getattr(mass.music, method_name) item_id, provider = _parse_ma_uri(uri) if action == "artist_albums" and provider != "library": # A provider URI (search result, track played from a streaming provider) would # return that provider's whole catalog: this action is library-scoped, so use the # library version of the artist (MA returns it when the artist is in the library). try: artist = await mass.music.get_item_by_uri(uri) except Exception as err: # noqa: BLE001 _LOGGER.warning("Subitems artist_albums: could not resolve %r: %s", uri, err) return None if artist.provider != "library": return [] item_id, provider = artist.item_id, "library" try: items = await fn(item_id, provider) except Exception as err: # noqa: BLE001 _LOGGER.warning("Subitems %s(%r, %r) failed: %s", method_name, item_id, provider, err) return None return [_normalize_library_item(_to_json_safe(i)) for i in items[:limit]] async def _get_artist_all_albums( hass: HomeAssistant, uri: str, limit: int = 200, ) -> list | None: """Fetch an artist's full album catalog (not just library/favorites). ``artist_albums`` (above) resolves the URI's own scheme for its provider, which for a ``library://...`` URI is the internal "library" pseudo-provider — that can only ever return what's already synced (i.e. favorites), it has no external catalog behind it. To get everything, resolve the artist's real provider mapping(s) and query those directly with ``in_library_only=False``. """ mass = _get_mass_client(hass) if mass is None: return None try: artist = await mass.music.get_item_by_uri(uri) except Exception as err: # noqa: BLE001 _LOGGER.warning("artist_albums_all: could not resolve artist %r: %s", uri, err) return None provider_mappings = list(getattr(artist, "provider_mappings", None) or []) if not provider_mappings: # Fallback: whatever provider the URI itself points to. item_id, provider = _parse_ma_uri(uri) provider_mappings = [ SimpleNamespace(item_id=item_id, provider_instance=provider, provider_domain=provider) ] candidates = [m for m in provider_mappings if m.provider_domain not in ("library", "builtin")] or provider_mappings seen_instances: set[str] = set() all_albums: list = [] for mapping in candidates: instance = mapping.provider_instance or mapping.provider_domain if instance in seen_instances: continue seen_instances.add(instance) try: albums = await mass.music.get_artist_albums(mapping.item_id, instance, in_library_only=False) except Exception as err: # noqa: BLE001 _LOGGER.debug("artist_albums_all: provider %s failed: %s", instance, err) continue all_albums.extend(albums) # Multiple providers can return the same album — dedupe by (name, year). deduped: dict[tuple[str, int | None], Any] = {} for album in all_albums: key = (album.name.strip().lower(), getattr(album, "year", None)) deduped.setdefault(key, album) normalized = [] for album in deduped.values(): item = _normalize_library_item(_to_json_safe(album)) item["year"] = getattr(album, "year", None) normalized.append(item) return normalized[:limit] async def _get_track_artist(hass: HomeAssistant, uri: str) -> list | None: """Return the main artist of a track, as a one-item list (empty if it has none). Used by the player tab's "artist" button: the media_player entity only exposes the artist *name*, the card needs a URI to open the artist page. Re-fetching the artist by its URI returns the library version when the artist is in the library (MA prefers it), so the page's favorites section keeps working. """ mass = _get_mass_client(hass) if mass is None: return None try: track = await mass.music.get_item_by_uri(uri) except Exception as err: # noqa: BLE001 _LOGGER.warning("track_artist: could not resolve %r: %s", uri, err) return None artists = list(getattr(track, "artists", None) or []) if not artists: return [] artist = artists[0] try: artist = await mass.music.get_item_by_uri(artist.uri) except Exception as err: # noqa: BLE001 _LOGGER.debug("track_artist: keeping the track's artist mapping for %r: %s", uri, err) return [_normalize_library_item(_to_json_safe(artist))] # ── Providers ───────────────────────────────────────────────────────────────── async def _get_providers_via_ma_client(hass: HomeAssistant) -> list | None: """Return available MA providers (domain + name) via the MA Python client. mass.providers returns list[ProviderInstance] in music_assistant_client. We access attributes directly to avoid _to_json_safe conversion issues (e.g. StrEnum not being a dataclass in certain model versions). """ mass = _get_mass_client(hass) if mass is None: return None try: raw = getattr(mass, "providers", None) if raw is None: _LOGGER.debug("mass.providers is None — MA provider list unavailable") return None # mass.providers is list[ProviderInstance]; guard against dict-like wrappers too items: Any = raw.values() if (isinstance(raw, dict) or hasattr(raw, "items")) else raw seen: set[str] = set() result = [] for p in items: try: # Access attributes directly — avoids _to_json_safe conversion issues provider_type = str(getattr(p, "type", "") or "").lower() if provider_type and provider_type != "music": continue domain = str(getattr(p, "domain", "") or "") if domain == "builtin": continue instance_id = str(getattr(p, "instance_id", "") or domain) name = str(getattr(p, "name", "") or domain) available = bool(getattr(p, "available", True)) if not instance_id or instance_id in seen: continue seen.add(instance_id) _LOGGER.debug("MA provider: instance_id=%r domain=%r name=%r available=%s", instance_id, domain, name, available) result.append({ "domain": domain, "instance_id": instance_id, "name": name, "available": available, }) except Exception as item_err: # noqa: BLE001 _LOGGER.debug("Skipping MA provider item: %s", item_err) continue _LOGGER.debug( "MA providers: %d total, %d available", len(result), sum(1 for r in result if r["available"]), ) return [r for r in result if r["available"]] except Exception as err: # noqa: BLE001 _LOGGER.warning("Failed to get MA providers: %s", err) return None # ── MA queue helpers ────────────────────────────────────────────────────────── def _resolve_ma_player_id(hass: HomeAssistant, entity_id: str) -> str | None: """Map a HA media_player entity_id to its Music Assistant player_id. Our media_player entities use unique_id = f"{UNIQUE_ID_PREFIX}{player_id}" (see entity.py). """ ent_reg = er.async_get(hass) entry = ent_reg.async_get(entity_id) if entry is None or not entry.unique_id: return None return entry.unique_id.removeprefix(UNIQUE_ID_PREFIX) async def _resolve_queue_id(hass: HomeAssistant, entity_id: str) -> tuple[Any | None, str | None]: """Return (mass_client, queue_id) for a HA media_player entity.""" mass = _get_mass_client(hass) if mass is None: return None, None ma_player_id = _resolve_ma_player_id(hass, entity_id) if not ma_player_id: return mass, None queue = await mass.player_queues.get_active_queue(ma_player_id) queue_id = queue.queue_id if queue is not None else ma_player_id return mass, queue_id def _normalize_queue_item(item: dict) -> dict: """Normalize a MA QueueItem dict for the frontend.""" name = item.get("name") or "" queue_item_id = item.get("queue_item_id") or "" duration = item.get("duration") or 0 raw_thumb = _extract_thumbnail(item) if not raw_thumb: raw_thumb = _extract_thumbnail(item.get("media_item") or {}) thumbnail = _make_thumb_url(raw_thumb) media_item = item.get("media_item") or {} uri = media_item.get("uri") or "" artist = "" artists = media_item.get("artists") or [] if artists and isinstance(artists[0], dict): artist = artists[0].get("name", "") media_type = media_item.get("media_type", "track") if isinstance(media_type, dict): media_type = media_type.get("value", "track") return { "queue_item_id": queue_item_id, "title": name or media_item.get("name", ""), "media_content_id": uri, "media_content_type": str(media_type), "media_artist": artist, "duration": float(duration) if duration else 0, "thumbnail": thumbnail, } async def _queue_jump_via_ma_client( hass: HomeAssistant, entity_id: str, index: int, ) -> bool: """Ask MA to jump to ``index`` within the player's existing queue.""" mass, queue_id = await _resolve_queue_id(hass, entity_id) if mass is None or queue_id is None: _LOGGER.warning("queue_jump: cannot resolve queue for %s", entity_id) return False try: await mass.player_queues.play_index(queue_id, index) _LOGGER.debug("queue_jump: queue=%s index=%d ok", queue_id, index) return True except Exception as err: # noqa: BLE001 _LOGGER.warning("queue_jump: play_index(%s, %d) failed: %s", queue_id, index, err) return False # ── HA Views ────────────────────────────────────────────────────────────────── class MusicAssistantSearchView(HomeAssistantView): """Proxy search requests to Music Assistant. GET /my_music_library/search?query=&limit=&library_only=true """ url = "/my_music_library/search" name = "my_music_library:search" requires_auth = True async def get(self, request: web.Request) -> web.Response: """Handle search request.""" hass: HomeAssistant = request.app["hass"] query = request.query.get("query", "").strip() if not query: return self.json_message("Missing 'query' parameter.", HTTPStatus.BAD_REQUEST) limit = min(int(request.query.get("limit", 25)), 100) library_only = request.query.get("library_only", "").lower() in ("1", "true", "yes") _LOGGER.debug("Search request: query=%r limit=%d library_only=%s", query, limit, library_only) result = await _search_via_ma_client(hass, query, limit, library_only=library_only) if result is not None: return web.json_response(result) _LOGGER.error("Search failed for query=%r", query) return self.json_message( "Could not search Music Assistant. Check HA logs (filter: my_music_library).", HTTPStatus.BAD_GATEWAY, ) class MusicAssistantLibraryView(HomeAssistantView): """Return library items from Music Assistant. GET /my_music_library/library?type=artists|albums|tracks|playlists&limit=25&favorite=true """ url = "/my_music_library/library" name = "my_music_library:library" requires_auth = True async def get(self, request: web.Request) -> web.Response: """Handle library request.""" hass: HomeAssistant = request.app["hass"] media_type = request.query.get("type", "").strip() if media_type not in ("artists", "albums", "tracks", "playlists", "radios"): return self.json_message("Invalid 'type' parameter.", HTTPStatus.BAD_REQUEST) limit = min(int(request.query.get("limit", 25)), 100) favorite = request.query.get("favorite", "true").lower() != "false" offset = max(0, int(request.query.get("offset", 0))) provider_instance = request.query.get("provider", "").strip() or None _LOGGER.debug("Library request: type=%s limit=%d offset=%d favorite=%s provider=%s", media_type, limit, offset, favorite, provider_instance) items = await _get_library_via_ma_client(hass, media_type, limit, favorite, offset, provider_instance) if items is None: _LOGGER.error("Library fetch failed for type=%s", media_type) return self.json_message( "Could not get library from Music Assistant. Check HA logs (filter: my_music_library).", HTTPStatus.BAD_GATEWAY, ) return web.json_response({"type": media_type, "items": items}) class MusicAssistantBrowseView(HomeAssistantView): """Browse the MA filesystem tree. GET /my_music_library/browse?uri= uri is optional — omit for root. """ url = "/my_music_library/browse" name = "my_music_library:browse" requires_auth = True async def get(self, request: web.Request) -> web.Response: """Handle browse request.""" hass: HomeAssistant = request.app["hass"] uri = request.query.get("uri", "").strip() or None limit = min(int(request.query.get("limit", 200)), 500) _LOGGER.debug("Browse request: uri=%r limit=%d", uri, limit) items = await _browse_via_ma_client(hass, uri, limit) if items is None: return self.json_message( "Could not browse Music Assistant. Check HA logs.", HTTPStatus.BAD_GATEWAY, ) return web.json_response({"uri": uri or "", "items": items}) class MusicAssistantRecommendationsView(HomeAssistantView): """Return recommendation folders from Music Assistant. GET /my_music_library/recommendations → {"folders": [{folder_id, name, icon, items: [...]}]} """ url = "/my_music_library/recommendations" name = "my_music_library:recommendations" requires_auth = True async def get(self, request: web.Request) -> web.Response: """Handle recommendations request.""" hass: HomeAssistant = request.app["hass"] folders = await _get_recommendations_via_ma_client(hass) if folders is None: return self.json_message( "Could not get recommendations from Music Assistant. Check HA logs.", HTTPStatus.BAD_GATEWAY, ) return web.json_response({"folders": folders}) class MusicAssistantSubitemsView(HomeAssistantView): """Return sub-items of a MA library item. GET /my_music_library/subitems?action=artist_albums|artist_albums_all|album_tracks|playlist_tracks|track_artist&uri= ``artist_albums`` is library/favorites-scoped; ``artist_albums_all`` resolves the artist's real provider mapping(s) to return its full catalog. ``track_artist`` returns the main artist of the track ``uri`` (zero or one item). """ url = "/my_music_library/subitems" name = "my_music_library:subitems" requires_auth = True async def get(self, request: web.Request) -> web.Response: """Handle subitems request.""" hass: HomeAssistant = request.app["hass"] action = request.query.get("action", "").strip() uri = request.query.get("uri", "").strip() limit = min(int(request.query.get("limit", 50)), 200) if action not in ( "artist_albums", "artist_albums_all", "album_tracks", "playlist_tracks", "track_artist", ): return self.json_message("Invalid 'action' parameter.", HTTPStatus.BAD_REQUEST) if not uri: return self.json_message("Missing 'uri' parameter.", HTTPStatus.BAD_REQUEST) _LOGGER.debug("Subitems request: action=%s uri=%s limit=%d", action, uri, limit) if action == "artist_albums_all": items = await _get_artist_all_albums(hass, uri, limit) elif action == "track_artist": items = await _get_track_artist(hass, uri) else: items = await _get_subitems(hass, action, uri, limit) if items is None: return self.json_message( "Could not get subitems from Music Assistant. Check HA logs.", HTTPStatus.BAD_GATEWAY, ) return web.json_response({"action": action, "items": items}) class MusicAssistantProvidersView(HomeAssistantView): """Return the list of available Music Assistant providers. GET /my_music_library/providers """ url = "/my_music_library/providers" name = "my_music_library:providers" requires_auth = True async def get(self, request: web.Request) -> web.Response: """Handle providers request.""" hass: HomeAssistant = request.app["hass"] providers = await _get_providers_via_ma_client(hass) return web.json_response({"providers": providers or []}) class PlayerQueueView(HomeAssistantView): """Per-player queue storage — accessible from any browser/device. GET /my_music_library/queue?player= → {"queue": [...], "source": ""} POST /my_music_library/queue body: {"player": "", "queue": [...], "source": ""} → {"ok": true} """ url = "/my_music_library/queue" name = "my_music_library:queue" requires_auth = True async def get(self, request: web.Request) -> web.Response: """Return the stored queue for a player.""" hass: HomeAssistant = request.app["hass"] player = request.query.get("player", "").strip() if not player: return self.json_message("Missing 'player' parameter.", HTTPStatus.BAD_REQUEST) queues: dict = hass.data.get(DOMAIN, {}).get("queues", {}) data = queues.get(player, {"queue": [], "source": None}) return web.json_response(data) async def post(self, request: web.Request) -> web.Response: """Save the queue for a player.""" hass: HomeAssistant = request.app["hass"] try: body: dict = await request.json() except Exception: # noqa: BLE001 return self.json_message("Invalid JSON body.", HTTPStatus.BAD_REQUEST) player = (body.get("player") or "").strip() if not player: return self.json_message("Missing 'player' field.", HTTPStatus.BAD_REQUEST) entry = { "queue": body.get("queue") or [], "source": body.get("source") or None, } domain_data = hass.data.setdefault(DOMAIN, {}) domain_data.setdefault("queues", {})[player] = entry await async_save_store(hass) return web.json_response({"ok": True}) class OutputsView(HomeAssistantView): """Audio outputs for the card's output panel (see outputs.py). GET /my_music_library/outputs → {"outputs": [...], "presets": [...]} POST /my_music_library/outputs body: {"action": "transfer", "source": "", "targets": ["", ...]} | {"action": "set_members", "leader": "", "add": [...], "remove": [...]} | {"action": "group_volume", "leader": "", "volume": 0-100} | {"action": "power", "player": "", "powered": true|false} | {"action": "save_preset", "name": "...", "leader": "", "members": [...], "id": ""} | {"action": "delete_preset", "id": "..."} → {"ok": true} (+ "preset" for save_preset) """ url = "/my_music_library/outputs" name = "my_music_library:outputs" requires_auth = True async def get(self, request: web.Request) -> web.Response: """Return the outputs and the group presets.""" hass: HomeAssistant = request.app["hass"] mass = outputs.get_mass(hass) return web.json_response({ "outputs": outputs.describe_outputs(hass, mass) if mass else [], "presets": outputs.get_presets(hass), }) async def post(self, request: web.Request) -> web.Response: """Run an output action.""" hass: HomeAssistant = request.app["hass"] try: body: dict = await request.json() except Exception: # noqa: BLE001 return self.json_message("Invalid JSON body.", HTTPStatus.BAD_REQUEST) def _list(key: str) -> list[str]: return [v for v in body.get(key) or [] if isinstance(v, str) and v] action = body.get("action") result: dict[str, Any] = {"ok": True} try: if action == "transfer": await outputs.async_transfer(hass, body.get("source") or "", _list("targets")) elif action == "set_members": await outputs.async_set_members(hass, body.get("leader") or "", _list("add"), _list("remove")) elif action == "group_volume": await outputs.async_group_volume(hass, body.get("leader") or "", int(body.get("volume", 0))) elif action == "power": await outputs.async_power(hass, body.get("player") or "", bool(body.get("powered"))) elif action == "save_preset": result["preset"] = await outputs.async_save_preset( hass, str(body.get("name") or ""), body.get("leader") or "", _list("members"), body.get("id"), ) elif action == "delete_preset": await outputs.async_delete_preset(hass, str(body.get("id") or "")) else: return self.json_message("Invalid 'action'.", HTTPStatus.BAD_REQUEST) except HomeAssistantError as err: _LOGGER.warning("outputs %s failed: %s", action, err) return self.json_message(str(err), HTTPStatus.BAD_GATEWAY) except (TypeError, ValueError) as err: return self.json_message(f"Invalid parameters: {err}", HTTPStatus.BAD_REQUEST) return web.json_response(result) class MAQueueView(HomeAssistantView): """Proxy to Music Assistant's native player queue. GET /my_music_library/ma_queue?player=&limit=50&offset=0 → {"items": [...], "queue_id": "..."} POST /my_music_library/ma_queue body: {"player": "", "action": "delete_item", "item_id": "..."} → {"ok": true} """ url = "/my_music_library/ma_queue" name = "my_music_library:ma_queue" requires_auth = True async def get(self, request: web.Request) -> web.Response: """Return the MA queue items for a player.""" hass: HomeAssistant = request.app["hass"] player = request.query.get("player", "").strip() if not player: return self.json_message("Missing 'player' parameter.", HTTPStatus.BAD_REQUEST) mass, queue_id = await _resolve_queue_id(hass, player) if mass is None or queue_id is None: _LOGGER.warning("ma_queue GET: cannot resolve queue for %s", player) return self.json_message( "Cannot resolve MA queue. Check HA logs.", HTTPStatus.BAD_GATEWAY, ) limit = min(int(request.query.get("limit", 50)), 500) offset = max(0, int(request.query.get("offset", 0))) try: items = await mass.player_queues.get_queue_items(queue_id, limit=limit, offset=offset) normalized = [_normalize_queue_item(_to_json_safe(i)) for i in items] _LOGGER.debug("ma_queue GET: queue=%s → %d items", queue_id, len(normalized)) return web.json_response({"items": normalized, "queue_id": queue_id}) except Exception as err: # noqa: BLE001 _LOGGER.warning("ma_queue GET failed: %s", err) return self.json_message( f"Failed to get queue items: {err}", HTTPStatus.BAD_GATEWAY, ) async def post(self, request: web.Request) -> web.Response: """Execute a queue action (delete_item).""" hass: HomeAssistant = request.app["hass"] try: body: dict = await request.json() except Exception: # noqa: BLE001 return self.json_message("Invalid JSON body.", HTTPStatus.BAD_REQUEST) player = (body.get("player") or "").strip() action = (body.get("action") or "").strip() if not player or not action: return self.json_message("Missing 'player' or 'action'.", HTTPStatus.BAD_REQUEST) mass, queue_id = await _resolve_queue_id(hass, player) if mass is None or queue_id is None: return self.json_message("Cannot resolve MA queue.", HTTPStatus.BAD_GATEWAY) if action == "delete_item": item_id = body.get("item_id") if item_id is None: return self.json_message("Missing 'item_id'.", HTTPStatus.BAD_REQUEST) try: await mass.player_queues.delete_item(queue_id, item_id) return web.json_response({"ok": True}) except Exception as err: # noqa: BLE001 _LOGGER.warning("ma_queue delete_item failed: %s", err) return self.json_message(f"delete_item failed: {err}", HTTPStatus.BAD_GATEWAY) if action == "clear": try: await mass.player_queues.clear(queue_id) return web.json_response({"ok": True}) except Exception as err: # noqa: BLE001 _LOGGER.warning("ma_queue clear failed: %s", err) return self.json_message(f"clear failed: {err}", HTTPStatus.BAD_GATEWAY) return self.json_message(f"Unknown action: {action}", HTTPStatus.BAD_REQUEST) class PlayerQueueJumpView(HomeAssistantView): """Jump to a specific index in the player's existing MA queue. POST /my_music_library/queue_jump body: {"player": "", "index": } → {"ok": true} on success, 502 otherwise """ url = "/my_music_library/queue_jump" name = "my_music_library:queue_jump" requires_auth = True async def post(self, request: web.Request) -> web.Response: """Handle queue jump request.""" hass: HomeAssistant = request.app["hass"] try: body: dict = await request.json() except Exception: # noqa: BLE001 return self.json_message("Invalid JSON body.", HTTPStatus.BAD_REQUEST) player = (body.get("player") or "").strip() try: index = int(body.get("index")) except (TypeError, ValueError): return self.json_message("Missing or invalid 'index'.", HTTPStatus.BAD_REQUEST) if not player or index < 0: return self.json_message("Missing 'player' or invalid 'index'.", HTTPStatus.BAD_REQUEST) ok = await _queue_jump_via_ma_client(hass, player, index) if not ok: return self.json_message( "Queue jump failed. Check HA logs (filter: my_music_library).", HTTPStatus.BAD_GATEWAY, ) return web.json_response({"ok": True}) class ImageProxyView(HomeAssistantView): """Proxy an image URL through HA to avoid mixed-content / CORS issues. GET /my_music_library/image_proxy?url= → binary image response """ url = "/my_music_library/image_proxy" name = "my_music_library:image_proxy" requires_auth = False async def get(self, request: web.Request) -> web.Response: """Fetch an image URL server-side and return it.""" hass: HomeAssistant = request.app["hass"] image_url = request.query.get("url", "").strip() if not image_url or not image_url.startswith("http"): return web.Response(status=HTTPStatus.BAD_REQUEST) session = async_get_clientsession(hass) try: async with session.get( image_url, timeout=aiohttp.ClientTimeout(total=10), ) as resp: if resp.status != 200: return web.Response(status=resp.status) body = await resp.read() return web.Response( body=body, content_type=resp.content_type or "image/jpeg", headers={"Cache-Control": "public, max-age=300"}, ) except Exception: # noqa: BLE001 return web.Response(status=HTTPStatus.BAD_GATEWAY) class MAThumbnailView(HomeAssistantView): """Proxy thumbnail requests through the MA server. GET /my_music_library/thumb?path= Resolves image paths server-side via the Music Assistant server, handling provider-specific auth (Plex tokens, etc.) and internal MA references that browsers cannot access directly. """ url = "/my_music_library/thumb" name = "my_music_library:thumb" requires_auth = False async def get(self, request: web.Request) -> web.Response: """Resolve an image path via MA and return the image bytes.""" hass: HomeAssistant = request.app["hass"] raw_path = request.query.get("path", "").strip() if not raw_path: return web.Response(status=HTTPStatus.BAD_REQUEST) ma_url = _get_mass_url(hass) session = async_get_clientsession(hass) urls_to_try: list[str] = [] if raw_path.startswith("http"): urls_to_try.append(raw_path) if ma_url: urls_to_try.append( f"{ma_url}/api/image/{quote(raw_path, safe='')}" ) elif ma_url: urls_to_try.append( f"{ma_url}/api/image/{quote(raw_path, safe='')}" ) if raw_path.startswith("/"): urls_to_try.append(f"{ma_url}{raw_path}") for image_url in urls_to_try: try: async with session.get( image_url, timeout=aiohttp.ClientTimeout(total=10), ) as resp: if resp.status != 200: continue ct = resp.content_type or "image/jpeg" if not ct.startswith("image"): continue body = await resp.read() return web.Response( body=body, content_type=ct, headers={"Cache-Control": "public, max-age=3600"}, ) except Exception: # noqa: BLE001 continue return web.Response(status=HTTPStatus.NOT_FOUND)