diff --git a/custom_components/bahmcloud_store/core.py b/custom_components/bahmcloud_store/core.py index ab9c880..9c9518f 100644 --- a/custom_components/bahmcloud_store/core.py +++ b/custom_components/bahmcloud_store/core.py @@ -34,7 +34,9 @@ BACKUP_META_FILENAME = ".bcs_backup_meta.json" # Optional HACS integrations index (GitHub repositories only). HACS_INTEGRATIONS_URL = "https://data-v2.hacs.xyz/integration/repositories.json" +HACS_INTEGRATIONS_DATA_URL = "https://data-v2.hacs.xyz/integration/data.json" HACS_DEFAULT_CATEGORY = "Integrations" +HACS_CACHE_TTL_SECONDS = 60 * 60 * 24 # 24h class BCSError(Exception): @@ -102,6 +104,11 @@ class BCSCore: # Persistent settings (UI toggles etc.) self.settings: dict[str, Any] = {"hacs_enabled": False} + # Cached HACS metadata (display names/descriptions). Loaded from storage. + self._hacs_meta_fetched_at: int = 0 + self._hacs_meta: dict[str, dict[str, Any]] = {} + self._hacs_meta_lock = asyncio.Lock() + # Phase F2: backups before install/update self._backup_root = Path(self.hass.config.path(".bcs_backups")) self._backup_keep_per_domain: int = 5 @@ -122,6 +129,19 @@ class BCSCore: # After a successful HA restart, restart-required is no longer relevant. self._clear_restart_required_issue() + # Load cached HACS metadata (optional; improves UX when HACS toggle is enabled). + try: + hc = await self.storage.get_hacs_cache() + if isinstance(hc, dict): + self._hacs_meta_fetched_at = int(hc.get("fetched_at") or 0) + repos = hc.get("repos") + if isinstance(repos, dict): + # Normalize to string keys + self._hacs_meta = {str(k): (v if isinstance(v, dict) else {}) for k, v in repos.items()} + except Exception: + self._hacs_meta_fetched_at = 0 + self._hacs_meta = {} + async def _read_manifest_version_async(self) -> str: def _read() -> str: try: @@ -257,6 +277,14 @@ class BCSCore: for r in merged.values(): r.provider = detect_provider(r.url) + # Apply cached HACS display metadata immediately (fast UX). + if hacs_enabled and hacs_repos: + self._apply_hacs_meta(merged) + + # Refresh HACS metadata in the background if cache is missing/stale. + if self._hacs_meta_needs_refresh(): + self.hass.async_create_task(self._refresh_hacs_meta_background()) + await self._enrich_installed_only(merged) self.repos = merged @@ -298,17 +326,158 @@ class BCSCore: if not full_name or "/" not in full_name: continue repo_id = f"hacs:{full_name.lower()}" + owner = full_name.split("/", 1)[0].strip() items.append( RepoItem( id=repo_id, + # Name is improved later via cached HACS meta (manifest.name). name=full_name, url=f"https://github.com/{full_name}", source="hacs", + owner=owner, + provider_repo_name=full_name, # keep stable owner/repo reference meta_category=HACS_DEFAULT_CATEGORY, ) ) return items + def _hacs_meta_needs_refresh(self) -> bool: + if not self._hacs_meta_fetched_at or not self._hacs_meta: + return True + age = int(time.time()) - int(self._hacs_meta_fetched_at) + return age > HACS_CACHE_TTL_SECONDS + + def _apply_hacs_meta(self, merged: dict[str, RepoItem]) -> None: + """Apply cached HACS metadata to matching repos (no I/O).""" + if not self._hacs_meta: + return + + def _full_name_from_repo(r: RepoItem) -> str | None: + # Prefer the original owner/repo (stable) if we kept it. + if r.provider_repo_name and "/" in str(r.provider_repo_name): + return str(r.provider_repo_name).strip() + # Fall back to URL path: https://github.com/owner/repo + try: + u = urlparse((r.url or "").strip()) + parts = [p for p in (u.path or "").strip("/").split("/") if p] + if len(parts) >= 2: + repo = parts[1] + if repo.endswith(".git"): + repo = repo[:-4] + return f"{parts[0]}/{repo}" + except Exception: + pass + return None + + for r in merged.values(): + if r.source != "hacs": + continue + + key = _full_name_from_repo(r) + if not key or "/" not in key: + continue + + meta = self._hacs_meta.get(key) + if not isinstance(meta, dict) or not meta: + continue + + # Prefer HACS manifest name as display name. + display_name = meta.get("name") + if isinstance(display_name, str) and display_name.strip(): + r.name = display_name.strip() + r.meta_name = display_name.strip() + + desc = meta.get("description") + if isinstance(desc, str) and desc.strip(): + r.meta_description = desc.strip() + + domain = meta.get("domain") + # We don't store domain in RepoItem fields, but keep it in meta_source for debugging. + # (Optional: extend RepoItem later if needed.) + if isinstance(domain, str) and domain.strip(): + # Keep under meta_source marker to help identify source. + pass + + r.meta_source = r.meta_source or "hacs" + r.meta_category = r.meta_category or HACS_DEFAULT_CATEGORY + + async def _refresh_hacs_meta_background(self) -> None: + """Fetch and cache HACS integration metadata in the background. + + Uses the official HACS data endpoint which includes manifest data. + This avoids per-repo GitHub calls and improves the UX (names/descriptions). + """ + async with self._hacs_meta_lock: + # Another task might have refreshed already. + if not self._hacs_meta_needs_refresh(): + return + + session = async_get_clientsession(self.hass) + headers = { + "User-Agent": "BahmcloudStore (Home Assistant)", + "Cache-Control": "no-cache, no-store, max-age=0", + "Pragma": "no-cache", + } + + try: + async with session.get(HACS_INTEGRATIONS_DATA_URL, timeout=120, headers=headers) as resp: + if resp.status != 200: + raise BCSError(f"HACS data.json returned {resp.status}") + data = await resp.json() + except Exception as e: + _LOGGER.warning("BCS HACS meta refresh failed: %s", e) + return + + # Build mapping owner/repo -> {name, description, domain} + meta_map: dict[str, dict[str, Any]] = {} + if isinstance(data, dict): + for _, obj in data.items(): + if not isinstance(obj, dict): + continue + full_name = obj.get("full_name") + if not isinstance(full_name, str) or "/" not in full_name: + continue + + manifest = obj.get("manifest") + mname = None + mdesc = None + mdomain = None + if isinstance(manifest, dict): + mname = manifest.get("name") + mdesc = manifest.get("description") + mdomain = manifest.get("domain") + + entry: dict[str, Any] = {} + if isinstance(mname, str) and mname.strip(): + entry["name"] = mname.strip() + if isinstance(mdesc, str) and mdesc.strip(): + entry["description"] = mdesc.strip() + if isinstance(mdomain, str) and mdomain.strip(): + entry["domain"] = mdomain.strip() + + if entry: + meta_map[full_name.strip()] = entry + + self._hacs_meta = meta_map + self._hacs_meta_fetched_at = int(time.time()) + + try: + await self.storage.set_hacs_cache({ + "fetched_at": self._hacs_meta_fetched_at, + "repos": self._hacs_meta, + }) + except Exception: + _LOGGER.debug("Failed to persist HACS cache", exc_info=True) + + # Apply meta to current repos and notify UI. + try: + self._apply_hacs_meta(self.repos) + except Exception: + pass + + _LOGGER.info("BCS HACS metadata cached: repos=%s", len(self._hacs_meta)) + self.signal_updated() + async def _enrich_and_resolve(self, merged: dict[str, RepoItem]) -> None: sem = asyncio.Semaphore(6)