diff --git a/.gitignore b/.gitignore index f6b156c..e7e8a0b 100644 --- a/.gitignore +++ b/.gitignore @@ -105,3 +105,4 @@ config.json *.pem licenses.json tools/licenses.md +/data/external_cache/icons/ diff --git a/data/external_cache/app_metadata.json b/data/external_cache/app_metadata.json index 5dfb8e9..74f9550 100644 --- a/data/external_cache/app_metadata.json +++ b/data/external_cache/app_metadata.json @@ -1,10 +1,14 @@ { + "com.android.systemui": { + "name": "Android TV", + "icon": "androidtv.png" + }, "com.google.android.tvlauncher": { - "name": "Android TV Home", - "icon": "data/external_cache/icons/androidtv.png" + "name": "Android TV", + "icon": "androidtv.png" }, "com.google.android.backdrop": { "name": "Backdrop Daydream", - "icon": "data/external_cache/icons/androidtv.png" + "icon": "androidtv.png" } } \ No newline at end of file diff --git a/intg-androidtv/external_metadata.py b/intg-androidtv/external_metadata.py index 8d55425..4e7b4a4 100644 --- a/intg-androidtv/external_metadata.py +++ b/intg-androidtv/external_metadata.py @@ -5,6 +5,7 @@ :license: MPL-2.0, see LICENSE for more details. """ +import asyncio import base64 import json import logging @@ -15,7 +16,7 @@ from urllib.parse import urlparse import google_play_scraper -import requests +import httpx from PIL import Image from PIL.Image import Resampling from pychromecast.controllers.media import MediaImage @@ -61,9 +62,13 @@ def _load_cache() -> Dict[str, Dict[str, str]]: if path.exists(): try: with open(path, "r", encoding="utf-8") as f: - return json.load(f) - except Exception: + cache = json.load(f) + _LOG.debug("Loaded metadata cache with %d entries", len(cache)) + return cache + except Exception as e: + _LOG.warning("Failed to load metadata cache: %s", e) return {} + _LOG.debug("Metadata cache file does not exist") return {} @@ -71,57 +76,83 @@ def _save_cache(cache: Dict[str, Dict[str, str]]) -> None: path = _get_metadata_file_path() with open(path, "w", encoding="utf-8") as f: json.dump(cache, f, indent=2) + _LOG.debug("Saved metadata cache with %d entries", len(cache)) # Metadata Fetch -def _download_and_resize_icon(url: str, package_id: str) -> str | None: +async def _download_and_resize_icon(url: str, package_id: str) -> str | None: + _LOG.debug("Downloading and resizing icon for %s from %s", package_id, url) try: - response = requests.get(url, timeout=10) - response.raise_for_status() + async with httpx.AsyncClient() as client: + response = await client.get(url, timeout=10) + response.raise_for_status() + + img_bytes = BytesIO(response.content) + + def resize_image() -> str: + img = Image.open(img_bytes) + img = img.resize(ICON_SIZE, Resampling.LANCZOS) + icon_path = _get_icon_path(package_id) + img.save(icon_path, format="PNG") + _LOG.debug("Saved resized icon to %s", icon_path) + return icon_path.name - img = Image.open(BytesIO(response.content)) - img = img.resize(ICON_SIZE, Resampling.LANCZOS) + filename = await asyncio.to_thread(resize_image) + return filename - icon_path = _get_icon_path(package_id) - img.save(icon_path, format="PNG") - return str(icon_path) except Exception as e: _LOG.warning("Failed to fetch icon for %s: %s", package_id, e) return None -def encode_icon_to_data_uri(icon_path: str) -> str: +async def encode_icon_to_data_uri(icon_name: str) -> str: """ Encode an image from a local file path or remote URL. Returns a base64-encoded PNG data URI. """ - if isinstance(icon_path, MediaImage): - icon_path = icon_path.url + _LOG.debug("Encoding icon to data URI: %s", icon_name) + if isinstance(icon_name, MediaImage): + icon_name = icon_name.url - # Already a base64 data URI - if isinstance(icon_path, str) and icon_path.startswith("data:image"): - return icon_path + if isinstance(icon_name, str) and icon_name.startswith("data:image"): + _LOG.debug("Icon is already a data URI") + return icon_name try: - if _is_url(icon_path): - response = requests.get(icon_path, timeout=10) - response.raise_for_status() - img = Image.open(BytesIO(response.content)) - else: + if _is_url(icon_name): + async with httpx.AsyncClient() as client: + response = await client.get(icon_name, timeout=10) + response.raise_for_status() + img_bytes = BytesIO(response.content) + + def encode_image() -> str: + img = Image.open(img_bytes) + img = img.convert("RGBA") + buffer = BytesIO() + img.save(buffer, format="PNG") + encoded = base64.b64encode(buffer.getvalue()).decode("utf-8") + return f"data:image/png;base64,{encoded}" + + return await asyncio.to_thread(encode_image) + + def load_and_encode() -> str: + icon_path = _get_icon_dir() / icon_name + if not icon_path.exists(): + raise FileNotFoundError(f"Icon not found: {icon_path}") with open(icon_path, "rb") as f: img = Image.open(f) - img.load() # Ensure the image is fully loaded before the file is closed - - img = img.convert("RGBA") + img.load() + img = img.convert("RGBA") + buffer = BytesIO() + img.save(buffer, format="PNG") + encoded = base64.b64encode(buffer.getvalue()).decode("utf-8") + return f"data:image/png;base64,{encoded}" - buffer = BytesIO() - img.save(buffer, format="PNG") - encoded = base64.b64encode(buffer.getvalue()).decode("utf-8") - return f"data:image/png;base64,{encoded}" + return await asyncio.to_thread(load_and_encode) except Exception as e: - _LOG.warning("Failed to encode icon to base64 for %s: %s", icon_path, e) + _LOG.warning("Failed to encode icon to base64 for %s: %s", icon_name, e) return "" @@ -130,22 +161,24 @@ def _is_url(path: str) -> bool: return parsed.scheme in ("http", "https") -def _fetch_google_play_metadata(package_id: str) -> Dict[str, str] | None: +async def _fetch_google_play_metadata(package_id: str) -> Dict[str, str] | None: + _LOG.debug("Fetching metadata for %s from Google Play", package_id) try: - app = google_play_scraper.app(package_id) + app = await asyncio.to_thread(google_play_scraper.app, package_id) name = app["title"] icon_url = app["icon"] - icon_path = _download_and_resize_icon(icon_url, package_id) + icon_name = await _download_and_resize_icon(icon_url, package_id) - return {"name": name, "icon": icon_path or ""} + _LOG.debug("Fetched metadata for %s: name='%s', icon='%s'", package_id, name, icon_name) + return {"name": name, "icon": icon_name or ""} except Exception as e: _LOG.warning("Google Play metadata fetch failed for %s: %s", package_id, e) return None -def get_app_metadata(package_id: str) -> Dict[str, str]: +async def get_app_metadata(package_id: str) -> Dict[str, str]: """ Fetch metadata for a mobile application specified by the package ID. @@ -161,22 +194,22 @@ def get_app_metadata(package_id: str) -> Dict[str, str]: it returns the package ID as the name and an empty string as the icon. :rtype: Dict[str, str] """ + _LOG.debug("Getting app metadata for %s", package_id) cache = _load_cache() if package_id in cache: - icon_path = cache[package_id].get("icon") - icon_data_uri = encode_icon_to_data_uri(icon_path) if icon_path else "" + _LOG.debug("Cache hit for %s", package_id) + icon_name = cache[package_id].get("icon") + icon_data_uri = await encode_icon_to_data_uri(icon_name) if icon_name else "" return {"name": cache[package_id]["name"], "icon": icon_data_uri} - # Try Google Play - metadata = _fetch_google_play_metadata(package_id) - # if not metadata: - # Additional Fallback option for the future maybe APKPure or another source - # metadata = fetch_fallback_metadata(package_id) + _LOG.debug("Cache miss for %s", package_id) + metadata = await _fetch_google_play_metadata(package_id) if metadata: cache[package_id] = metadata _save_cache(cache) - icon_data_uri = encode_icon_to_data_uri(metadata["icon"]) if metadata["icon"] else "" + icon_data_uri = await encode_icon_to_data_uri(metadata["icon"]) if metadata["icon"] else "" return {"name": metadata["name"], "icon": icon_data_uri} + _LOG.debug("Falling back to default metadata for %s", package_id) return {"name": package_id, "icon": ""} diff --git a/intg-androidtv/tv.py b/intg-androidtv/tv.py index 2493d74..198bca5 100644 --- a/intg-androidtv/tv.py +++ b/intg-androidtv/tv.py @@ -590,7 +590,7 @@ def disconnect(self) -> None: self.events.emit(Events.DISCONNECTED, self._identifier) # Callbacks - def _apply_current_app_metadata(self, current_app: str) -> dict: + async def _apply_current_app_metadata(self, current_app: str) -> dict: update = {} # Track state of data sources @@ -613,64 +613,78 @@ def _apply_current_app_metadata(self, current_app: str) -> dict: break # Try external metadata - metadata = get_app_metadata(current_app) if self._device_config.use_external_metadata else None + metadata = await get_app_metadata(current_app) if self._device_config.use_external_metadata else None if metadata: external_name = metadata.get("name") external_icon = metadata.get("icon") if external_name: self._media_app = external_name + if external_icon: + self._app_image_url = external_icon + + _LOG.debug("App metadata: %s", metadata) # Determine final name/title to use name_to_use = offline_name or offline_match or external_name or current_app update[MediaAttr.SOURCE] = name_to_use - update[MediaAttr.MEDIA_TITLE] = name_to_use + if not self._media_title and not self._media_image_url: + update[MediaAttr.MEDIA_TITLE] = name_to_use # Determine which icon to use icon_to_use = None - if self._device_config.use_external_metadata and self._use_app_url: + if self._device_config.use_external_metadata or self._use_app_url: if external_icon: - icon_to_use = encode_icon_to_data_uri(external_icon) + icon_to_use = external_icon else: - icon_to_use = "" # Explicitly clear if expected but missing + icon_to_use = "" elif self._media_image_url: - icon_to_use = encode_icon_to_data_uri(self._media_image_url) - - if icon_to_use is not None: - update[MediaAttr.MEDIA_IMAGE_URL] = icon_to_use + icon_to_use = await encode_icon_to_data_uri(self._media_image_url) # Special case handling for Android TV system apps if current_app in ("com.google.android.tvlauncher", "com.android.systemui"): update[MediaAttr.STATE] = media_player.States.ON.value - update[MediaAttr.MEDIA_TITLE] = "Android TV Home" - update[MediaAttr.SOURCE] = "Android TV Home" - update[MediaAttr.MEDIA_IMAGE_URL] = encode_icon_to_data_uri("data/external_cache/icons/androidtv.png") + update[MediaAttr.MEDIA_TITLE] = "Android TV" + update[MediaAttr.SOURCE] = "Android TV" + update[MediaAttr.MEDIA_IMAGE_URL] = await encode_icon_to_data_uri("androidtv.png") elif current_app == "com.google.android.backdrop": update[MediaAttr.STATE] = media_player.States.STANDBY.value update[MediaAttr.MEDIA_TITLE] = "" - update[MediaAttr.MEDIA_IMAGE_URL] = encode_icon_to_data_uri("data/external_cache/icons/androidtv.png") + update[MediaAttr.MEDIA_IMAGE_URL] = await encode_icon_to_data_uri("androidtv.png") else: update[MediaAttr.STATE] = media_player.States.PLAYING.value + # Skip applying app icon if media image from cast is present + if not self._media_image_url: + if not icon_to_use: + update[MediaAttr.MEDIA_IMAGE_URL] = await encode_icon_to_data_uri("androidtv.png") + else: + update[MediaAttr.MEDIA_IMAGE_URL] = icon_to_use return update def _is_on_updated(self, is_on: bool) -> None: """Notify that the Android TV power state is updated.""" + asyncio.create_task(self._handle_is_on_updated(is_on)) + + async def _handle_is_on_updated(self, is_on: bool): _LOG.info("[%s] is on: %s", self.log_id, is_on) current_app = self._atv.current_app or "" if is_on: self._chromecast_connect() - update = self._apply_current_app_metadata(current_app) + update = await self._apply_current_app_metadata(current_app) update[MediaAttr.STATE] = media_player.States.ON.value - else: - update = self._apply_current_app_metadata(current_app) + update = await self._apply_current_app_metadata(current_app) update[MediaAttr.STATE] = media_player.States.OFF.value + self.events.emit(Events.UPDATE, self._identifier, update) def _current_app_updated(self, current_app: str) -> None: """Notify that the current app on Android TV is updated.""" + asyncio.create_task(self._handle_current_app_updated(current_app)) + + async def _handle_current_app_updated(self, current_app: str): _LOG.debug("[%s] current_app: %s", self.log_id, current_app) - update = self._apply_current_app_metadata(current_app) + update = await self._apply_current_app_metadata(current_app) self.events.emit(Events.UPDATE, self._identifier, update) def _volume_info_updated(self, volume_info: dict[str, str | bool]) -> None: @@ -818,9 +832,18 @@ def new_connection_status(self, status: ConnectionStatus) -> None: def new_media_status(self, status: MediaStatus) -> None: """Receive new media status event from Google cast.""" - # For debugging, too verbose - # _LOG.debug("[%s] Update from Chromecast info : %s", self.log_id, status) + if not self._loop or not self._loop.is_running(): + _LOG.warning("[%s] No running event loop for handling new media status", self.log_id) + return + + try: + asyncio.run_coroutine_threadsafe(self._handle_new_media_status(status), self._loop) + except Exception as e: + _LOG.error("[%s] Failed to schedule media status handler: %s", self.log_id, e) + + async def _handle_new_media_status(self, status: MediaStatus): update = {} + if ( status.player_state and GOOGLE_CAST_MEDIA_STATES_MAP.get(status.player_state, media_player.States.PLAYING) != self._player_state @@ -829,50 +852,57 @@ def new_media_status(self, status: MediaStatus) -> None: self._player_state = GOOGLE_CAST_MEDIA_STATES_MAP.get(status.player_state, media_player.States.PLAYING) self._last_update_position_time = 0 update[MediaAttr.STATE] = self._player_state + if status.album_name != self._media_album: - self._media_album = status.album_name if status.album_name else "" + self._media_album = status.album_name or "" update[MediaAttr.MEDIA_ALBUM] = self._media_album + if status.artist != self._media_artist: - self._media_artist = status.artist if status.artist else "" + self._media_artist = status.artist or "" update[MediaAttr.MEDIA_ARTIST] = self._media_artist + if status.title != self._media_title: current_title = self.media_title - self._media_title = status.title if status.title else "" + self._media_title = status.title or "" if current_title != self.media_title: _LOG.debug("[%s] Chromecast Media info updated : %s", self.log_id, status) update[MediaAttr.MEDIA_TITLE] = self.media_title + current_time = int(status.current_time) if status.current_time else 0 duration = int(status.duration) if status.duration else 0 - chanded_duration = False + changed_duration = False + if duration != self._media_duration: self._media_duration = duration update[MediaAttr.MEDIA_DURATION] = self._media_duration - chanded_duration = True + changed_duration = True + # Update position every 30 seconds - if chanded_duration or ( + if changed_duration or ( current_time != self._media_position and self._last_update_position_time + 30 < time.time() ): self._media_position = current_time update[MediaAttr.MEDIA_POSITION] = self._media_position update[MediaAttr.MEDIA_DURATION] = self._media_duration self._last_update_position_time = time.time() + if ( status.metadata_type and GOOGLE_CAST_MEDIA_TYPES_MAP.get(status.metadata_type, MediaType.VIDEO) != self._media_type ): - self._media_type = GOOGLE_CAST_MEDIA_TYPES_MAP.get(self._media_type, MediaType.VIDEO) + self._media_type = GOOGLE_CAST_MEDIA_TYPES_MAP.get(status.metadata_type, MediaType.VIDEO) update[MediaAttr.MEDIA_TYPE] = self._media_type if status.images and len(status.images) > 0 and status.images[0].url != self._media_image_url: self._media_image_url = status.images[0].url - update[MediaAttr.MEDIA_IMAGE_URL] = encode_icon_to_data_uri(self._media_image_url) + update[MediaAttr.MEDIA_IMAGE_URL] = await encode_icon_to_data_uri(self._media_image_url) self._use_app_url = False else: self._media_image_url = None if self._device_config.use_external_metadata: self._use_app_url = True if self._app_image_url: - update[MediaAttr.MEDIA_IMAGE_URL] = encode_icon_to_data_uri(self._app_image_url) + update[MediaAttr.MEDIA_IMAGE_URL] = await encode_icon_to_data_uri(self._app_image_url) if update: # filter media_image_url property @@ -881,6 +911,7 @@ def new_media_status(self, status: MediaStatus) -> None: if MediaAttr.MEDIA_IMAGE_URL in log_upd: log_upd[MediaAttr.MEDIA_IMAGE_URL] = "***" _LOG.debug("[%s] Update remote with Chromecast info : %s", self.log_id, log_upd) + self.events.emit(Events.UPDATE, self._identifier, update) def load_media_failed(self, queue_item_id: int, error_code: int) -> None: @@ -889,13 +920,23 @@ def load_media_failed(self, queue_item_id: int, error_code: int) -> None: def new_cast_status(self, status: CastStatus) -> None: """Receive new cast event from Google cast.""" _LOG.debug("[%s] Received Chromecast cast status : %s", self.log_id, status) + + if not self._loop or not self._loop.is_running(): + _LOG.warning("[%s] No running event loop for cast status", self.log_id) + return + + try: + asyncio.run_coroutine_threadsafe(self._handle_new_cast_status(status), self._loop) + except Exception as e: + _LOG.error("[%s] Failed to schedule cast status handler: %s", self.log_id, e) + + async def _handle_new_cast_status(self, status: CastStatus): current_title = self.media_title if status.display_name: self._media_app = status.display_name if current_title != self.media_title: update = {MediaAttr.MEDIA_TITLE: self.media_title} - _LOG.debug("[%s] Update remote with Chromecast info : %s", self.log_id, update) self.events.emit(Events.UPDATE, self._identifier, update) diff --git a/requirements.txt b/requirements.txt index 734f367..6138722 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,4 +4,5 @@ pyee~=13.0.0 google_play_scraper==1.2.7 pillow>=11.2.1 requests>=2.32 -pychromecast~=14.0.7 \ No newline at end of file +pychromecast~=14.0.7 +httpx~=0.28.1 \ No newline at end of file