From 9ba1b32e053ecfa2772136983ca0f40172671883 Mon Sep 17 00:00:00 2001 From: Thomas Mason Date: Wed, 23 Apr 2025 22:25:26 +0100 Subject: [PATCH 01/46] updates to external_metadata.py for async and adding httpx dependancy --- intg-androidtv/external_metadata.py | 67 +++++++++-------------------- requirements.txt | 3 +- 2 files changed, 22 insertions(+), 48 deletions(-) diff --git a/intg-androidtv/external_metadata.py b/intg-androidtv/external_metadata.py index 8d55425..1166b9c 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 @@ -74,32 +75,26 @@ def _save_cache(cache: Dict[str, Dict[str, str]]) -> None: # Metadata Fetch -def _download_and_resize_icon(url: str, package_id: str) -> str | None: +async def _download_and_resize_icon_async(url: str, package_id: str) -> str | None: try: - response = requests.get(url, timeout=10) - response.raise_for_status() - - img = Image.open(BytesIO(response.content)) - img = img.resize(ICON_SIZE, Resampling.LANCZOS) + async with httpx.AsyncClient(timeout=10) as client: + response = await client.get(url) + response.raise_for_status() + img = Image.open(BytesIO(response.content)) + img = img.resize(ICON_SIZE, Resampling.LANCZOS) - icon_path = _get_icon_path(package_id) - img.save(icon_path, format="PNG") - return str(icon_path) + 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: - """ - 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 - # Already a base64 data URI if isinstance(icon_path, str) and icon_path.startswith("data:image"): return icon_path @@ -111,10 +106,9 @@ def encode_icon_to_data_uri(icon_path: str) -> str: else: 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.load() img = img.convert("RGBA") - buffer = BytesIO() img.save(buffer, format="PNG") encoded = base64.b64encode(buffer.getvalue()).decode("utf-8") @@ -130,53 +124,32 @@ 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_async(package_id: str) -> Dict[str, str] | None: 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_path = await _download_and_resize_icon_async(icon_url, package_id) return {"name": name, "icon": icon_path 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]: - """ - Fetch metadata for a mobile application specified by the package ID. - - The metadata includes the application name and its icon encoded as a data URI. - If metadata is found in a locally cached source, it is fetched from the cache. - Otherwise, metadata is retrieved from external sources such as Google Play. - - :param package_id: The unique package identifier for the application. - :type package_id: str - :return: A dictionary containing the application's metadata. The dictionary - includes the 'name' of the application and the 'icon', which is the - application's icon encoded as a data URI. If no metadata is found, - it returns the package ID as the name and an empty string as the icon. - :rtype: Dict[str, str] - """ +async def get_app_metadata_async(package_id: str) -> Dict[str, str]: 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 "" 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) - + metadata = await _fetch_google_play_metadata_async(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 = ( + encode_icon_to_data_uri(metadata["icon"]) if metadata["icon"] else "" + ) return {"name": metadata["name"], "icon": icon_data_uri} return {"name": package_id, "icon": ""} diff --git a/requirements.txt b/requirements.txt index 734f367..e0afc6d 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 \ No newline at end of file From a558e1072c2ec5ebb363b8cf07c460a066a4b79f Mon Sep 17 00:00:00 2001 From: Thomas Mason Date: Wed, 23 Apr 2025 23:20:50 +0100 Subject: [PATCH 02/46] fixes bugs in 64 ands 63 --- intg-androidtv/driver.py | 44 ++++-- intg-androidtv/external_metadata.py | 124 +++++++++++----- intg-androidtv/tv.py | 214 ++++++++++++++++++++-------- 3 files changed, 276 insertions(+), 106 deletions(-) diff --git a/intg-androidtv/driver.py b/intg-androidtv/driver.py index 771eac6..53a3a8f 100644 --- a/intg-androidtv/driver.py +++ b/intg-androidtv/driver.py @@ -39,7 +39,9 @@ async def on_connect(): """When the UCR2 connects, all configured Android TV devices are getting connected.""" _LOG.debug("Client connect command: connecting device(s)") - await api.set_device_state(ucapi.DeviceStates.CONNECTED) # just to make sure the device state is set + await api.set_device_state( + ucapi.DeviceStates.CONNECTED + ) # just to make sure the device state is set for atv in _configured_android_tvs.values(): # start background task _LOOP.create_task(atv.connect()) @@ -95,7 +97,9 @@ async def on_subscribe_entities(entity_ids) -> None: state = media_player.States.UNAVAILABLE else: state = media_player.States.ON if atv.is_on else media_player.States.OFF - api.configured_entities.update_attributes(entity_id, {media_player.Attributes.STATE: state}) + api.configured_entities.update_attributes( + entity_id, {media_player.Attributes.STATE: state} + ) _LOOP.create_task(atv.connect()) continue @@ -103,7 +107,9 @@ async def on_subscribe_entities(entity_ids) -> None: if device: _add_configured_android_tv(device) else: - _LOG.error("Failed to subscribe entity %s: no Android TV instance found", entity_id) + _LOG.error( + "Failed to subscribe entity %s: no Android TV instance found", entity_id + ) @api.listens_to(ucapi.Events.UNSUBSCRIBE_ENTITIES) @@ -144,7 +150,9 @@ async def media_player_cmd_handler( android_tv = _configured_android_tvs[atv_id] - _LOG.info("[%s] command: %s %s", android_tv.log_id, cmd_id, params if params else "") + _LOG.info( + "[%s] command: %s %s", android_tv.log_id, cmd_id, params if params else "" + ) if cmd_id == media_player.Commands.ON: return await android_tv.turn_on() @@ -178,8 +186,12 @@ async def handle_connected(identifier: str): config.devices.update(device) # TODO is this the correct state? - api.configured_entities.update_attributes(identifier, {media_player.Attributes.STATE: media_player.States.STANDBY}) - await api.set_device_state(ucapi.DeviceStates.CONNECTED) # just to make sure the device state is set + api.configured_entities.update_attributes( + identifier, {media_player.Attributes.STATE: media_player.States.STANDBY} + ) + await api.set_device_state( + ucapi.DeviceStates.CONNECTED + ) # just to make sure the device state is set async def handle_disconnected(identifier: str): @@ -237,7 +249,9 @@ async def handle_android_tv_update(atv_id: str, update: dict[str, Any]) -> None: log_upd = copy(update) if MediaAttr.MEDIA_IMAGE_URL in log_upd: log_upd[MediaAttr.MEDIA_IMAGE_URL] = "***" - _LOG.debug("[%s] device update: %s", device.name if device else atv_id, log_upd) + _LOG.debug( + "[%s] device update: %s", device.name if device else atv_id, log_upd + ) old_state = ( configured_entity.attributes[MediaAttr.STATE] @@ -290,7 +304,9 @@ async def handle_android_tv_update(atv_id: str, update: dict[str, Any]) -> None: def _add_configured_android_tv(device: config.AtvDevice, connect: bool = True) -> None: - profile = device_profile.match(device.manufacturer, device.model, device.use_chromecast) + profile = device_profile.match( + device.manufacturer, device.model, device.use_chromecast + ) # the device should not yet be configured, but better be safe if device.id in _configured_android_tvs: @@ -308,7 +324,9 @@ def _add_configured_android_tv(device: config.AtvDevice, connect: bool = True) - android_tv.events.on(tv.Events.DISCONNECTED, handle_disconnected) android_tv.events.on(tv.Events.AUTH_ERROR, handle_authentication_error) android_tv.events.on(tv.Events.UPDATE, handle_android_tv_update) - android_tv.events.on(tv.Events.IP_ADDRESS_CHANGED, handle_android_tv_address_change) + android_tv.events.on( + tv.Events.IP_ADDRESS_CHANGED, handle_android_tv_address_change + ) _configured_android_tvs[device.id] = android_tv _LOG.info( @@ -378,7 +396,9 @@ def on_device_added(device: config.AtvDevice) -> None: def on_device_removed(device: config.AtvDevice | None) -> None: """Handle a removed device in the configuration.""" if device is None: - _LOG.debug("Configuration cleared, disconnecting & removing all configured Android TV instances") + _LOG.debug( + "Configuration cleared, disconnecting & removing all configured Android TV instances" + ) for atv in _configured_android_tvs.values(): atv.disconnect() atv.events.remove_all_listeners() @@ -419,7 +439,9 @@ async def main(): device_profile.load(profile_path) # load paired devices - config.devices = config.Devices(api.config_dir_path, on_device_added, on_device_removed) + config.devices = config.Devices( + api.config_dir_path, on_device_added, on_device_removed + ) # best effort migration (if required): network might not be available during startup if config.devices.migration_required(): await config.devices.migrate() diff --git a/intg-androidtv/external_metadata.py b/intg-androidtv/external_metadata.py index 1166b9c..1d168a3 100644 --- a/intg-androidtv/external_metadata.py +++ b/intg-androidtv/external_metadata.py @@ -33,27 +33,34 @@ def _get_cache_root() -> Path: config_home = Path(os.environ.get("UC_DATA_HOME", "./data")) cache_root = config_home / CACHE_ROOT cache_root.mkdir(parents=True, exist_ok=True) + _LOG.debug("Cache root path: %s", cache_root) return cache_root def _get_metadata_dir() -> Path: metadata_dir = _get_cache_root() metadata_dir.mkdir(parents=True, exist_ok=True) + _LOG.debug("Metadata directory path: %s", metadata_dir) return metadata_dir def _get_icon_dir() -> Path: icon_dir = _get_cache_root() / ICON_SUBDIR icon_dir.mkdir(parents=True, exist_ok=True) + _LOG.debug("Icon directory path: %s", icon_dir) return icon_dir def _get_metadata_file_path() -> Path: - return _get_metadata_dir() / "app_metadata.json" + path = _get_metadata_dir() / "app_metadata.json" + _LOG.debug("Metadata file path: %s", path) + return path def _get_icon_path(package_id: str) -> Path: - return _get_icon_dir() / f"{package_id}.png" + path = _get_icon_dir() / f"{package_id}.png" + _LOG.debug("Icon file path for %s: %s", package_id, path) + return path # Cache Management @@ -62,9 +69,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 {} @@ -72,50 +83,79 @@ 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 -async def _download_and_resize_icon_async(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: - async with httpx.AsyncClient(timeout=10) as client: - response = await client.get(url) + async with httpx.AsyncClient() as client: + response = await client.get(url, timeout=10) response.raise_for_status() - img = Image.open(BytesIO(response.content)) - img = img.resize(ICON_SIZE, Resampling.LANCZOS) + 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") - return str(icon_path) + _LOG.debug("Saved resized icon to %s", icon_path) + return icon_path.name + + filename = await asyncio.to_thread(resize_image) + return filename + 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: - if isinstance(icon_path, MediaImage): - icon_path = icon_path.url +async def encode_icon_to_data_uri(icon_name: str) -> str: + _LOG.debug("Encoding icon to data URI: %s", icon_name) + if isinstance(icon_name, MediaImage): + icon_name = icon_name.url - 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)) + 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) else: - with open(icon_path, "rb") as f: - img = Image.open(f) - 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}" + 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() + 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(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 "" @@ -124,32 +164,44 @@ def _is_url(path: str) -> bool: return parsed.scheme in ("http", "https") -async def _fetch_google_play_metadata_async(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 = await asyncio.to_thread(google_play_scraper.app, package_id) + name = app["title"] icon_url = app["icon"] - icon_path = await _download_and_resize_icon_async(icon_url, package_id) - return {"name": name, "icon": icon_path or ""} + icon_name = await _download_and_resize_icon(icon_url, package_id) + + _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 -async def get_app_metadata_async(package_id: str) -> Dict[str, str]: +async def get_app_metadata(package_id: str) -> 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} - metadata = await _fetch_google_play_metadata_async(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 "" + 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..26884dd 100644 --- a/intg-androidtv/tv.py +++ b/intg-androidtv/tv.py @@ -55,9 +55,7 @@ _LOG = logging.getLogger(__name__) CONNECTION_TIMEOUT: float = 10.0 -"""Android TV device connection timeout in seconds.""" BACKOFF_MAX: int = 30 -"""Maximum backoff duration in seconds.""" MIN_RECONNECT_DELAY: float = 0.5 BACKOFF_FACTOR: float = 1.5 @@ -118,7 +116,9 @@ class DeviceState(IntEnum): # https://github.com/home-assistant/core/blob/fd1f0b0efeb5231d3ee23d1cb2a10cdeff7c23f1/homeassistant/components/denonavr/media_player.py def async_handle_atvlib_errors( func: Callable[Concatenate[_AndroidTvT, _P], Awaitable[ucapi.StatusCodes | None]], -) -> Callable[Concatenate[_AndroidTvT, _P], Coroutine[Any, Any, ucapi.StatusCodes | None]]: +) -> Callable[ + Concatenate[_AndroidTvT, _P], Coroutine[Any, Any, ucapi.StatusCodes | None] +]: """Log errors occurred when calling an Android TV device. Decorates methods of AndroidTv class. @@ -127,21 +127,32 @@ def async_handle_atvlib_errors( """ @wraps(func) - async def wrapper(self: _AndroidTvT, *args: _P.args, **kwargs: _P.kwargs) -> ucapi.StatusCodes: + async def wrapper( + self: _AndroidTvT, *args: _P.args, **kwargs: _P.kwargs + ) -> ucapi.StatusCodes: try: # use the same exceptions as the func is throwing (e.g. AndroidTVRemote.send_key_command) state = self.state if state != DeviceState.CONNECTED: - if state in (DeviceState.DISCONNECTED, DeviceState.CONNECTING) or self.is_on is None: + if ( + state in (DeviceState.DISCONNECTED, DeviceState.CONNECTING) + or self.is_on is None + ): raise ConnectionClosed("Disconnected from device") if state in (DeviceState.AUTH_ERROR, DeviceState.PAIRING_ERROR): - raise InvalidAuth("Invalid authentication, device requires to be paired again") + raise InvalidAuth( + "Invalid authentication, device requires to be paired again" + ) raise CannotConnect(f"Device connection not active (state={state})") # workaround for "swallowed commands" since _atv.send_key_command doesn't provide a result # pylint: disable=W0212 if ( - not (self._atv and self._atv._remote_message_protocol and self._atv._remote_message_protocol.transport) + not ( + self._atv + and self._atv._remote_message_protocol + and self._atv._remote_message_protocol.transport + ) or self._atv._remote_message_protocol.transport.is_closing() ): _LOG.warning( @@ -162,7 +173,9 @@ async def wrapper(self: _AndroidTvT, *args: _P.args, **kwargs: _P.kwargs) -> uca _LOG.error("[%s] Cannot send command: %s", self.log_id, ex) return ucapi.StatusCodes.CONFLICT except ValueError as ex: - _LOG.error("[%s] Cannot send command, invalid key_code: %s", self.log_id, ex) + _LOG.error( + "[%s] Cannot send command, invalid key_code: %s", self.log_id, ex + ) return ucapi.StatusCodes.BAD_REQUEST return wrapper @@ -243,7 +256,9 @@ async def init(self, max_timeout: int | None = None) -> bool: :return: True if connected or connecting, False if timeout occurred. """ if self._state in (DeviceState.INITIALIZING, DeviceState.CONNECTING): - _LOG.debug("[%s] Skipping init task: connection task already running", self.log_id) + _LOG.debug( + "[%s] Skipping init task: connection task already running", self.log_id + ) return True self._state = DeviceState.INITIALIZING @@ -378,7 +393,9 @@ async def start_pairing(self) -> ucapi.StatusCodes: return ucapi.StatusCodes.SERVICE_UNAVAILABLE except InvalidAuth as ex: self._state = DeviceState.AUTH_ERROR - _LOG.error("[%s] Authentication error in start pairing: %s", self.log_id, ex) + _LOG.error( + "[%s] Authentication error in start pairing: %s", self.log_id, ex + ) return ucapi.StatusCodes.UNAUTHORIZED async def finish_pairing(self, pin: str) -> ucapi.StatusCodes: @@ -449,7 +466,9 @@ async def connect(self, max_timeout: int | None = None) -> bool: self._reconnect_delay = MIN_RECONNECT_DELAY except InvalidAuth: self._state = DeviceState.AUTH_ERROR - _LOG.error("[%s] Invalid authentication for %s", self.log_id, self._identifier) + _LOG.error( + "[%s] Invalid authentication for %s", self.log_id, self._identifier + ) self.events.emit(Events.AUTH_ERROR, self._identifier) break except (CannotConnect, ConnectionClosed, asyncio.TimeoutError) as ex: @@ -520,7 +539,9 @@ def _chromecast_connect(self): try: self._chromecast.register_status_listener(self) - self._chromecast.socket_client.media_controller.register_status_listener(self) + self._chromecast.socket_client.media_controller.register_status_listener( + self + ) self._chromecast.register_connection_listener(self) _LOG.info("[%s] Chromecast connecting", self.log_id) self._chromecast.wait(timeout=CONNECTION_TIMEOUT) @@ -590,21 +611,18 @@ 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 offline_name = None offline_match = None external_name = None external_icon = None - # Try offline ID mapping first if current_app in apps.IdMappings: offline_name = apps.IdMappings[current_app] self._media_app = offline_name - # Try fuzzy offline name matching if ID mapping failed if not offline_name: for query, name in apps.NameMatching.items(): if query in current_app: @@ -612,65 +630,78 @@ def _apply_current_app_metadata(self, current_app: str) -> dict: self._media_app = name 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 - # 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 - # Determine which icon to use icon_to_use = None if self._device_config.use_external_metadata and 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) + icon_to_use = await encode_icon_to_data_uri(self._media_image_url) if icon_to_use is not None: update[MediaAttr.MEDIA_IMAGE_URL] = icon_to_use - # 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_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 + update[MediaAttr.MEDIA_IMAGE_URL] = await encode_icon_to_data_uri( + "androidtv.png" + ) + + _LOG.debug("%s", update) 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: @@ -684,7 +715,9 @@ def _is_available_updated(self, is_available: bool): """Notify that the Android TV is ready to receive commands or is unavailable.""" _LOG.info("[%s] is_available: %s", self.log_id, is_available) self._state = DeviceState.CONNECTED if is_available else DeviceState.CONNECTING - self.events.emit(Events.CONNECTED if is_available else Events.DISCONNECTED, self.identifier) + self.events.emit( + Events.CONNECTED if is_available else Events.DISCONNECTED, self.identifier + ) def _update_app_list(self) -> None: update = {} @@ -757,7 +790,9 @@ async def select_source(self, source: str) -> ucapi.StatusCodes: return await self._launch_app(source) @async_handle_atvlib_errors - async def _send_command(self, keycode: int | str, action: KeyPress = KeyPress.SHORT) -> ucapi.StatusCodes: + async def _send_command( + self, keycode: int | str, action: KeyPress = KeyPress.SHORT + ) -> ucapi.StatusCodes: """ Send a key press to Android TV. @@ -812,75 +847,118 @@ async def _switch_input(self, source: str) -> ucapi.StatusCodes: def new_connection_status(self, status: ConnectionStatus) -> None: """Receive new connection status event from Google cast.""" - _LOG.debug("[%s] Received Chromecast connection status : %s", self.log_id, status) + _LOG.debug( + "[%s] Received Chromecast connection status : %s", self.log_id, status + ) if status.status == "CONNECTED": _LOG.debug("[%s] Chromecast connected", self.log_id) 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 + and GOOGLE_CAST_MEDIA_STATES_MAP.get( + status.player_state, media_player.States.PLAYING + ) + != self._player_state ): - # PLAYING, PAUSED, IDLE - self._player_state = GOOGLE_CAST_MEDIA_STATES_MAP.get(status.player_state, media_player.States.PLAYING) + 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) + _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 - # Update position every 30 seconds - if chanded_duration or ( - current_time != self._media_position and self._last_update_position_time + 30 < time.time() + changed_duration = True + + 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 + 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: + 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 if _LOG.isEnabledFor(logging.DEBUG): log_upd = copy(update) 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) + _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,21 +967,39 @@ 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) + _LOG.debug( + "[%s] Update remote with Chromecast info : %s", self.log_id, update + ) self.events.emit(Events.UPDATE, self._identifier, update) async def media_seek(self, position: float) -> ucapi.StatusCodes: """Seek the media at the given position.""" try: if self._chromecast: - self._chromecast.media_controller.seek(position, timeout=CONNECTION_TIMEOUT) + self._chromecast.media_controller.seek( + position, timeout=CONNECTION_TIMEOUT + ) return ucapi.StatusCodes.OK except Exception as ex: _LOG.error("[%s] Chromecast error seeking command : %s", self.log_id, ex) From 15df3faa5fadd2fa57225c9b7ee4383e497b10ea Mon Sep 17 00:00:00 2001 From: Thomas Mason Date: Wed, 23 Apr 2025 23:21:44 +0100 Subject: [PATCH 03/46] update default metadata --- data/external_cache/app_metadata.json | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) 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 From b20e50075fd5403154a18ee7fdd34945ab3ab08d Mon Sep 17 00:00:00 2001 From: Thomas Mason Date: Wed, 23 Apr 2025 23:47:02 +0100 Subject: [PATCH 04/46] further tweaks --- intg-androidtv/tv.py | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/intg-androidtv/tv.py b/intg-androidtv/tv.py index 26884dd..57fd67b 100644 --- a/intg-androidtv/tv.py +++ b/intg-androidtv/tv.py @@ -640,13 +640,18 @@ async def _apply_current_app_metadata(self, current_app: str) -> dict: external_icon = metadata.get("icon") if external_name: self._media_app = external_name + if external_icon: + self._app_image_url = external_icon + + _LOG.debug("%s", metadata) 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 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 = external_icon else: @@ -654,29 +659,31 @@ async def _apply_current_app_metadata(self, current_app: str) -> dict: elif self._media_image_url: icon_to_use = await encode_icon_to_data_uri(self._media_image_url) - if icon_to_use is not None: - update[MediaAttr.MEDIA_IMAGE_URL] = icon_to_use - 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_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] = await encode_icon_to_data_uri( "androidtv.png" ) + else: update[MediaAttr.STATE] = media_player.States.PLAYING.value - update[MediaAttr.MEDIA_IMAGE_URL] = await encode_icon_to_data_uri( - "androidtv.png" - ) + 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 - _LOG.debug("%s", update) + # _LOG.debug("%s", update) return update From 74e8fd4450e4e39617f897fb9b882c0abaa5e349 Mon Sep 17 00:00:00 2001 From: Thomas Mason Date: Wed, 23 Apr 2025 23:52:18 +0100 Subject: [PATCH 05/46] further tweaks --- intg-androidtv/tv.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/intg-androidtv/tv.py b/intg-androidtv/tv.py index 57fd67b..9a25ebc 100644 --- a/intg-androidtv/tv.py +++ b/intg-androidtv/tv.py @@ -676,12 +676,14 @@ async def _apply_current_app_metadata(self, current_app: str) -> dict: else: update[MediaAttr.STATE] = media_player.States.PLAYING.value - 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 + # 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 # _LOG.debug("%s", update) From 4709da4f8d4754391782be9344f652a7dd69c589 Mon Sep 17 00:00:00 2001 From: Thomas Mason Date: Thu, 24 Apr 2025 00:27:13 +0100 Subject: [PATCH 06/46] adb poc --- intg-androidtv/adb_tv.py | 78 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 intg-androidtv/adb_tv.py diff --git a/intg-androidtv/adb_tv.py b/intg-androidtv/adb_tv.py new file mode 100644 index 0000000..b1c321c --- /dev/null +++ b/intg-androidtv/adb_tv.py @@ -0,0 +1,78 @@ +import asyncio +import os +import re +from typing import List + +from adb_shell.adb_device_async import AdbDeviceTcpAsync +from adb_shell.auth.keygen import keygen +from adb_shell.auth.sign_pythonrsa import PythonRSASigner + +# Key file paths +ADBKEY = "./adbkey" +ADBKEY_PUB = "./adbkey.pub" + +# Android TV IP address +HOST = "192.168.0.250" +PORT = 5555 + + +def load_or_generate_adb_keys() -> PythonRSASigner: + """Ensure ADB RSA keys exist and return the signer.""" + if not os.path.exists(ADBKEY) or not os.path.exists(ADBKEY_PUB): + print("🔑 ADB keys not found, generating new keys...") + keygen(ADBKEY) + print("✅ Keys generated.") + + with open(ADBKEY) as f: + priv = f.read() + with open(ADBKEY_PUB) as f: + pub = f.read() + return PythonRSASigner(pub, priv) + + +async def get_installed_apps(device: AdbDeviceTcpAsync) -> List[str]: + """Retrieve list of installed apps.""" + output = await device.shell("pm list packages") + return sorted(line.replace("package:", "").strip() for line in output.splitlines()) + + +async def get_current_app(device: AdbDeviceTcpAsync) -> str | None: + """Return the currently focused app (foreground).""" + output = await device.shell( + "dumpsys window windows | grep -E 'mCurrentFocus|mFocusedApp'" + ) + match = re.search(r"([a-zA-Z0-9_.]+)/[a-zA-Z0-9_.]+", output) + return match.group(1) if match else None + + +async def connect_and_run(): + signer = load_or_generate_adb_keys() + device = AdbDeviceTcpAsync(HOST, PORT, default_transport_timeout_s=9.0) + + print(f"🔌 Connecting to {HOST}:{PORT}...") + try: + await device.connect(rsa_keys=[signer], auth_timeout_s=10) + print("✅ Connected successfully.") + + # Test shell command + output = await device.shell("echo Hello from Android TV") + print(f"🟢 Shell output:\n{output.strip()}") + + # Get current foreground app + current_app = await get_current_app(device) + print(f"📱 Current app: {current_app or 'Unknown'}") + + # List installed packages + apps = await get_installed_apps(device) + print(f"📦 Installed apps ({len(apps)} total):") + for app in apps: # Show top 10 + print(f"{app}") + + except Exception as e: + print(f"❌ Error: {e}") + finally: + await device.close() + + +if __name__ == "__main__": + asyncio.run(connect_and_run()) From 27ec593fc1e94fc9ecd9c185d4639bdd08af9c21 Mon Sep 17 00:00:00 2001 From: Thomas Mason Date: Thu, 24 Apr 2025 01:00:06 +0100 Subject: [PATCH 07/46] update --- intg-androidtv/adb_tv.py | 99 ++++++++++++++++++++++++++++++++++------ 1 file changed, 86 insertions(+), 13 deletions(-) diff --git a/intg-androidtv/adb_tv.py b/intg-androidtv/adb_tv.py index b1c321c..a0d802a 100644 --- a/intg-androidtv/adb_tv.py +++ b/intg-androidtv/adb_tv.py @@ -32,44 +32,117 @@ def load_or_generate_adb_keys() -> PythonRSASigner: async def get_installed_apps(device: AdbDeviceTcpAsync) -> List[str]: """Retrieve list of installed apps.""" - output = await device.shell("pm list packages") + output = await device.shell("pm list packages -3 -e") return sorted(line.replace("package:", "").strip() for line in output.splitlines()) async def get_current_app(device: AdbDeviceTcpAsync) -> str | None: """Return the currently focused app (foreground).""" - output = await device.shell( - "dumpsys window windows | grep -E 'mCurrentFocus|mFocusedApp'" - ) + output = await device.shell("dumpsys window | grep mCurrentFocus") match = re.search(r"([a-zA-Z0-9_.]+)/[a-zA-Z0-9_.]+", output) return match.group(1) if match else None +async def get_media_info(device): + output = await device.shell("dumpsys media_session") + sessions = output.split("Sessions Stack") + if len(sessions) < 2: + return None + + media_info = {} + # Find the top (active) session + active_session = sessions[1] + + title = re.search(r"title=(.*)", active_session) + artist = re.search(r"artist=(.*)", active_session) + state = re.search(r"state=(\d+)", active_session) + + if title: + media_info["title"] = title.group(1).strip() + if artist: + media_info["artist"] = artist.group(1).strip() + if state: + media_info["state"] = int(state.group(1).strip()) + + return media_info or None + + +async def get_media_metadata(device): + output = await device.shell("dumpsys media_session") + + # Naive parsing (for proof of concept) + current_entry = {} + lines = output.splitlines() + in_metadata = False + for line in lines: + line = line.strip() + + if "MediaSessionRecord" in line: + current_entry = {} + + if "package=" in line: + current_entry["package"] = line.split("package=")[-1] + + if "state=PlaybackState" in line: + current_entry["state"] = line + + if "metadata=MediaMetadata" in line: + in_metadata = True + + elif in_metadata: + if "=" in line: + key, val = map(str.strip, line.split("=", 1)) + current_entry[key] = val + elif line == "}": + in_metadata = False + break + + return current_entry + + async def connect_and_run(): signer = load_or_generate_adb_keys() device = AdbDeviceTcpAsync(HOST, PORT, default_transport_timeout_s=9.0) - print(f"🔌 Connecting to {HOST}:{PORT}...") + print(f"Connecting to {HOST}:{PORT}...") try: await device.connect(rsa_keys=[signer], auth_timeout_s=10) - print("✅ Connected successfully.") + print("Connected successfully.") # Test shell command output = await device.shell("echo Hello from Android TV") - print(f"🟢 Shell output:\n{output.strip()}") + print(f"Shell output:\n{output.strip()}") # Get current foreground app current_app = await get_current_app(device) - print(f"📱 Current app: {current_app or 'Unknown'}") + print(f"Current app: {current_app or 'Unknown'}") + + print(await device.shell("getprop ro.product.model")) + print(await device.shell("getprop ro.product.manufacturer")) + + # print(await device.shell("dumpsys activity activities | grep mResumedActivity")) + # print(await device.shell("dumpsys activity recents | grep RecentTaskInfo")) + print(await device.shell("input keyevent KEYCODE_A")) + # keycode for the letter A # List installed packages - apps = await get_installed_apps(device) - print(f"📦 Installed apps ({len(apps)} total):") - for app in apps: # Show top 10 - print(f"{app}") + # apps = await get_installed_apps(device) + # print(f"📦 Installed apps ({len(apps)} total):") + # for app in apps: + # print(f"{app}") + + # print(await get_media_metadata(device)) + # media_output = await get_media_info(device) + # if media_output: + # print("🎵 Media Info:") + # print(f"Title: {media_output.get('title', 'Unknown')}") + # print(f"Artist: {media_output.get('artist', 'Unknown')}") + # print(f"State: {media_output.get('state', 'Unknown')}") + # else: + # print("🎵 No media session found.") except Exception as e: - print(f"❌ Error: {e}") + print(f"Error: {e}") finally: await device.close() From a7f53d2eb00e2e97db01512bd02b8b01d42ec065 Mon Sep 17 00:00:00 2001 From: Thomas Mason Date: Tue, 29 Apr 2025 16:11:18 +0100 Subject: [PATCH 08/46] update adb attempt --- src/adb_tv.py | 180 ++++++++++--------------------- src/config.py | 4 + src/setup_flow.py | 268 ++++++++++++++++++++++++++++++++++++++++++---- 3 files changed, 310 insertions(+), 142 deletions(-) diff --git a/src/adb_tv.py b/src/adb_tv.py index a0d802a..ed8d93c 100644 --- a/src/adb_tv.py +++ b/src/adb_tv.py @@ -1,151 +1,83 @@ import asyncio import os import re -from typing import List +from pathlib import Path +from typing import Dict, List, Optional from adb_shell.adb_device_async import AdbDeviceTcpAsync from adb_shell.auth.keygen import keygen from adb_shell.auth.sign_pythonrsa import PythonRSASigner -# Key file paths -ADBKEY = "./adbkey" -ADBKEY_PUB = "./adbkey.pub" +ADB_CERTS_DIR = Path(os.environ.get("UC_CONFIG_HOME", "./config")) / "certs" +ADB_CERTS_DIR.mkdir(parents=True, exist_ok=True) -# Android TV IP address -HOST = "192.168.0.250" -PORT = 5555 +def get_adb_key_paths(device_id: str) -> tuple[Path, Path]: + """Return the path to the private and public adb keys for a given device.""" + priv = ADB_CERTS_DIR / f"adb_{device_id}" + pub = ADB_CERTS_DIR / f"adb_{device_id}.pub" + return priv, pub -def load_or_generate_adb_keys() -> PythonRSASigner: - """Ensure ADB RSA keys exist and return the signer.""" - if not os.path.exists(ADBKEY) or not os.path.exists(ADBKEY_PUB): - print("🔑 ADB keys not found, generating new keys...") - keygen(ADBKEY) - print("✅ Keys generated.") - with open(ADBKEY) as f: +def load_or_generate_adb_keys(device_id: str) -> PythonRSASigner: + """Ensure ADB RSA keys exist for the device and return the signer.""" + priv_path, pub_path = get_adb_key_paths(device_id) + + if not priv_path.exists() or not pub_path.exists(): + keygen(str(priv_path)) + + with open(priv_path) as f: priv = f.read() - with open(ADBKEY_PUB) as f: + with open(pub_path) as f: pub = f.read() return PythonRSASigner(pub, priv) -async def get_installed_apps(device: AdbDeviceTcpAsync) -> List[str]: - """Retrieve list of installed apps.""" - output = await device.shell("pm list packages -3 -e") - return sorted(line.replace("package:", "").strip() for line in output.splitlines()) - - -async def get_current_app(device: AdbDeviceTcpAsync) -> str | None: - """Return the currently focused app (foreground).""" - output = await device.shell("dumpsys window | grep mCurrentFocus") - match = re.search(r"([a-zA-Z0-9_.]+)/[a-zA-Z0-9_.]+", output) - return match.group(1) if match else None - +async def adb_connect(device_id: str, host: str, port: int = 5555) -> Optional[AdbDeviceTcpAsync]: + signer = load_or_generate_adb_keys(device_id) + device = AdbDeviceTcpAsync(host, port, default_transport_timeout_s=9.0) -async def get_media_info(device): - output = await device.shell("dumpsys media_session") - sessions = output.split("Sessions Stack") - if len(sessions) < 2: + try: + await device.connect(rsa_keys=[signer], auth_timeout_s=20) + return device + except Exception as e: + print(f"ADB connection failed to {host}:{port} — {e}") return None - media_info = {} - # Find the top (active) session - active_session = sessions[1] - - title = re.search(r"title=(.*)", active_session) - artist = re.search(r"artist=(.*)", active_session) - state = re.search(r"state=(\d+)", active_session) - - if title: - media_info["title"] = title.group(1).strip() - if artist: - media_info["artist"] = artist.group(1).strip() - if state: - media_info["state"] = int(state.group(1).strip()) - - return media_info or None - - -async def get_media_metadata(device): - output = await device.shell("dumpsys media_session") - - # Naive parsing (for proof of concept) - current_entry = {} - lines = output.splitlines() - in_metadata = False - for line in lines: - line = line.strip() - - if "MediaSessionRecord" in line: - current_entry = {} - - if "package=" in line: - current_entry["package"] = line.split("package=")[-1] - - if "state=PlaybackState" in line: - current_entry["state"] = line - - if "metadata=MediaMetadata" in line: - in_metadata = True - - elif in_metadata: - if "=" in line: - key, val = map(str.strip, line.split("=", 1)) - current_entry[key] = val - elif line == "}": - in_metadata = False - break - - return current_entry +async def get_installed_apps(device: AdbDeviceTcpAsync) -> Dict[str, Dict[str, str]]: + """Retrieve list of installed non-system apps in structured format.""" + output = await device.shell("pm list packages -3 -e") + packages = sorted(line.replace("package:", "").strip() for line in output.splitlines()) + return { + package: {"url": f"market://launch?id={package}"} + for package in packages + } +async def is_authorised(device: AdbDeviceTcpAsync) -> bool: + try: + result = await device.shell("echo ADB_OK") + return "ADB_OK" in result + except Exception: + return False -async def connect_and_run(): - signer = load_or_generate_adb_keys() - device = AdbDeviceTcpAsync(HOST, PORT, default_transport_timeout_s=9.0) +async def test_connection(device_id: str, host: str) -> None: + device = await adb_connect(device_id, host) + if not device: + return - print(f"Connecting to {HOST}:{PORT}...") - try: - await device.connect(rsa_keys=[signer], auth_timeout_s=10) - print("Connected successfully.") - - # Test shell command - output = await device.shell("echo Hello from Android TV") - print(f"Shell output:\n{output.strip()}") - - # Get current foreground app - current_app = await get_current_app(device) - print(f"Current app: {current_app or 'Unknown'}") - - print(await device.shell("getprop ro.product.model")) - print(await device.shell("getprop ro.product.manufacturer")) - - # print(await device.shell("dumpsys activity activities | grep mResumedActivity")) - # print(await device.shell("dumpsys activity recents | grep RecentTaskInfo")) - print(await device.shell("input keyevent KEYCODE_A")) - # keycode for the letter A - - # List installed packages - # apps = await get_installed_apps(device) - # print(f"📦 Installed apps ({len(apps)} total):") - # for app in apps: - # print(f"{app}") - - # print(await get_media_metadata(device)) - # media_output = await get_media_info(device) - # if media_output: - # print("🎵 Media Info:") - # print(f"Title: {media_output.get('title', 'Unknown')}") - # print(f"Artist: {media_output.get('artist', 'Unknown')}") - # print(f"State: {media_output.get('state', 'Unknown')}") - # else: - # print("🎵 No media session found.") + if await is_authorised(device): + print("ADB authorisation confirmed.") + print("Current app:", await get_current_app(device)) + print("Installed apps:", await get_installed_apps(device)) + else: + print("Device not authorised. Please check the TV for an ADB prompt.") - except Exception as e: - print(f"Error: {e}") - finally: - await device.close() + await device.close() if __name__ == "__main__": - asyncio.run(connect_and_run()) + import sys + if len(sys.argv) != 3: + print("Usage: python adb_tv.py ") + else: + asyncio.run(test_connection(sys.argv[1], sys.argv[2])) diff --git a/src/config.py b/src/config.py index b37b66f..f2e1110 100644 --- a/src/config.py +++ b/src/config.py @@ -38,6 +38,8 @@ class AtvDevice: """Enable External Metadata.""" use_chromecast: bool = False """Enable Chromecast features.""" + use_adb: bool = False + """Enable ADB features.""" class _EnhancedJSONEncoder(json.JSONEncoder): @@ -133,6 +135,7 @@ def update(self, atv: AtvDevice) -> bool: item.auth_error = atv.auth_error item.use_external_metadata = atv.use_external_metadata item.use_chromecast = atv.use_chromecast + item.use_adb = atv.use_adb return self.store() return False @@ -236,6 +239,7 @@ def load(self) -> bool: item.get("auth_error", False), item.get("use_external_metadata", False), item.get("use_chromecast", False), + item.get("use_adb", False), ) self._config.append(atv) return True diff --git a/src/setup_flow.py b/src/setup_flow.py index 4fff16f..eddc6a3 100644 --- a/src/setup_flow.py +++ b/src/setup_flow.py @@ -8,6 +8,10 @@ import asyncio import logging from enum import IntEnum +from adb_shell.adb_device_async import AdbDeviceTcpAsync +from adb_shell.auth.sign_pythonrsa import PythonRSASigner +from adb_shell.auth.keygen import keygen +import os import ucapi from ucapi import ( @@ -26,6 +30,7 @@ import discover import tv from config import AtvDevice +import adb_tv _LOG = logging.getLogger(__name__) @@ -38,8 +43,8 @@ class SetupSteps(IntEnum): DISCOVER = 2 DEVICE_CHOICE = 3 PAIRING_PIN = 4 - RECONFIGURE = 5 - + APP_SELECTION = 6 + RECONFIGURE = 7 _setup_step = SetupSteps.INIT _cfg_add_device: bool = False @@ -48,6 +53,8 @@ class SetupSteps(IntEnum): _use_external_metadata: bool = False _reconfigured_device: AtvDevice | None = None _use_chromecast: bool = False +_use_adb: bool = False +_adb_device_id: str = "" # TODO #9 externalize language texts _user_input_discovery = RequestUserInput( @@ -108,6 +115,8 @@ async def driver_setup_handler(msg: SetupDriver) -> SetupAction: return await handle_device_choice(msg) if _setup_step == SetupSteps.PAIRING_PIN and "pin" in msg.input_values: return await handle_user_data_pin(msg) + if _setup_step == SetupSteps.APP_SELECTION and "app_selection" in msg.input_values: + return await _handle_app_selection(msg) if _setup_step == SetupSteps.RECONFIGURE: return await _handle_device_reconfigure(msg) _LOG.error("No or invalid user response was received: %s", msg) @@ -300,6 +309,7 @@ async def handle_configuration_mode( use_external_metadata = ( selected_device.use_external_metadata if selected_device.use_external_metadata else False ) + use_adb = selected_device.use_adb if selected_device.use_adb else False return RequestUserInput( { @@ -325,7 +335,15 @@ async def handle_configuration_mode( }, "field": {"checkbox": {"value": use_external_metadata}}, }, - ], + { + "id": "adb", + "label": { + "en": "Preview feature: Enable ADB connection (for app list)", + "de": "Vorschaufunktion: Aktiviere ADB Verbindung (für App-Browsing)", + "fr": "Fonctionnalité en aperçu: Activer la connexion ADB (pour la navigation dans les applications)", + }, + "field": {"checkbox": {"value": use_adb}}, + }, ], ) case "reset": config.devices.clear() # triggers device instance removal @@ -446,6 +464,15 @@ async def _handle_discovery(msg: UserDataResponse) -> RequestUserInput | SetupEr }, "field": {"checkbox": {"value": False}}, }, + { + "id": "adb", + "label": { + "en": "Preview feature: Enable ADB connection (for app list)", + "de": "Vorschaufunktion: Aktiviere ADB Verbindung (für App-Browsing)", + "fr": "Fonctionnalité en aperçu: Activer la connexion ADB (pour la navigation dans les applications)", + }, + "field": {"checkbox": {"value": False}}, + }, ], ) @@ -463,10 +490,12 @@ async def handle_device_choice(msg: UserDataResponse) -> RequestUserInput | Setu global _use_chromecast global _setup_step global _use_external_metadata + global _use_adb choice = msg.input_values["choice"] _use_external_metadata = msg.input_values.get("external_metadata", "false") == "true" _use_chromecast = msg.input_values.get("chromecast", "false") == "true" + _use_adb = msg.input_values.get("adb", "false") == "true" name = "" for discovered_tv in _discovered_android_tvs: @@ -484,6 +513,7 @@ async def handle_device_choice(msg: UserDataResponse) -> RequestUserInput | Setu id="", use_external_metadata=False, use_chromecast=False, + use_adb=False, ), ) _LOG.info("Chosen Android TV: %s. Start pairing process...", choice) @@ -516,7 +546,70 @@ async def handle_device_choice(msg: UserDataResponse) -> RequestUserInput | Setu return _setup_error_from_device_state(_pairing_android_tv.state) -async def handle_user_data_pin(msg: UserDataResponse) -> SetupComplete | SetupError: +# async def handle_user_data_pin(msg: UserDataResponse) -> SetupComplete | SetupError: +# """ +# Process user data pairing pin response in a setup process. +# +# Driver setup callback to provide requested user data during the setup process. +# +# :param msg: response data from the requested user data +# :return: the setup action on how to continue: SetupComplete if a valid Android TV device was chosen. +# """ +# global _pairing_android_tv +# +# if _pairing_android_tv is None: +# _LOG.error("Can't handle pairing pin: no device instance! Aborting setup") +# return SetupError() +# +# _LOG.info("[%s] User has entered the PIN", _pairing_android_tv.log_id) +# +# res = await _pairing_android_tv.finish_pairing(msg.input_values["pin"]) +# _pairing_android_tv.disconnect() +# +# device_info = None +# +# # Connect again to retrieve device identifier (with init()) and additional device information (with connect()) +# if res == ucapi.StatusCodes.OK: +# _LOG.info( +# "[%s] Pairing done, retrieving device information", +# _pairing_android_tv.log_id, +# ) +# res = ucapi.StatusCodes.SERVER_ERROR +# timeout = int(tv.CONNECTION_TIMEOUT) +# if await _pairing_android_tv.init(timeout) and await _pairing_android_tv.connect(timeout): +# device_info = _pairing_android_tv.device_info or {} +# if config.devices.assign_default_certs_to_device(_pairing_android_tv.identifier, True): +# res = ucapi.StatusCodes.OK +# _pairing_android_tv.disconnect() +# +# if res != ucapi.StatusCodes.OK: +# state = _pairing_android_tv.state +# _LOG.info("[%s] Setup failed: %s (state=%s)", _pairing_android_tv.log_id, res, state) +# _pairing_android_tv = None +# return _setup_error_from_device_state(state) +# +# device = AtvDevice( +# id=_pairing_android_tv.identifier, +# name=_pairing_android_tv.name, +# address=_pairing_android_tv.address, +# use_external_metadata=_use_external_metadata, +# use_chromecast=_use_chromecast, +# manufacturer=device_info.get("manufacturer", ""), +# model=device_info.get("model", ""), +# ) +# +# config.devices.add_or_update(device) # triggers AndroidTv instance creation +# config.devices.store() +# +# # ATV device connection will be triggered with subscribe_entities request +# _pairing_android_tv = None +# await asyncio.sleep(1) +# _LOG.info("[%s] Setup successfully completed for %s", device.name, device.id) +# return SetupComplete() + +import logging + +async def handle_user_data_pin(msg: UserDataResponse) -> RequestUserInput | SetupComplete | SetupError: """ Process user data pairing pin response in a setup process. @@ -527,6 +620,8 @@ async def handle_user_data_pin(msg: UserDataResponse) -> SetupComplete | SetupEr """ global _pairing_android_tv + _LOG.debug("Entered handle_user_data_pin with msg: %s", msg) + if _pairing_android_tv is None: _LOG.error("Can't handle pairing pin: no device instance! Aborting setup") return SetupError() @@ -534,23 +629,101 @@ async def handle_user_data_pin(msg: UserDataResponse) -> SetupComplete | SetupEr _LOG.info("[%s] User has entered the PIN", _pairing_android_tv.log_id) res = await _pairing_android_tv.finish_pairing(msg.input_values["pin"]) - _pairing_android_tv.disconnect() + _LOG.debug("[%s] finish_pairing result: %s", _pairing_android_tv.log_id, res) - device_info = None + _pairing_android_tv.disconnect() + _LOG.debug("[%s] Disconnected after pairing attempt", _pairing_android_tv.log_id) - # Connect again to retrieve device identifier (with init()) and additional device information (with connect()) if res == ucapi.StatusCodes.OK: - _LOG.info( - "[%s] Pairing done, retrieving device information", - _pairing_android_tv.log_id, - ) + _LOG.info("[%s] Pairing done, retrieving device information", _pairing_android_tv.log_id) res = ucapi.StatusCodes.SERVER_ERROR timeout = int(tv.CONNECTION_TIMEOUT) - if await _pairing_android_tv.init(timeout) and await _pairing_android_tv.connect(timeout): + _LOG.debug("[%s] Attempting to initialize and connect with timeout: %d", _pairing_android_tv.log_id, timeout) + + if _use_adb: + _LOG.debug("ADB is enabled, proceeding with ADB setup") + + if not msg.input_values.get("adb", False): + _LOG.error("ADB setup failed: 'use_adb' not found in input values") + return SetupError() + + from adb_tv import adb_connect, is_authorised, get_installed_apps + + device_id = _pairing_android_tv.identifier + ip_address = _pairing_android_tv.address + _LOG.debug("Attempting ADB setup for device_id: %s, ip_address: %s", device_id, ip_address) + + adb_device = await adb_connect(device_id, ip_address) + if not adb_device: + return SetupError(error_type=IntegrationSetupError.AUTHORIZATION_ERROR) + + if not await is_authorised(adb_device): + return SetupError(error_type=IntegrationSetupError.AUTHORIZATION_ERROR) + + _LOG.debug("ADB authorisation confirmed") + from apps import Apps + + adb_apps = await get_installed_apps(adb_device) # dict[str, dict[str, str]] + all_apps = {**Apps, **adb_apps} # ADB apps override Apps if same name + + + _LOG.debug("Retrieved apps: %s", all_apps) + await adb_device.close() + + _setup_step = SetupSteps.APP_SELECTION + return RequestUserInput( + title={ + "en": "Select visible apps", + "de": "Wähle sichtbare Apps", + "fr": "Sélectionnez les applications visibles", + }, + settings=[ + { + "id": "visible_apps", + "label": { + "en": "Choose apps to show", + "de": "Wähle Apps zur Anzeige", + "fr": "Choisir les applications à afficher", + }, + "field": { + "multichoice": { + "items": [ + { + "id": package, + "label": {"en": details.get("name", package)} + } + for package, details in sorted(all_apps.items()) + ], + "value": [], + } + }, + } + ], + ) + + else: + _LOG.debug("ADB is not enabled, skipping to setup completion") + return await handle_setup_completion(res) + +async def handle_setup_completion(res) -> SetupComplete: + global _pairing_android_tv + + device_info = None + timeout = int(tv.CONNECTION_TIMEOUT) + + if await _pairing_android_tv.init(timeout): + _LOG.debug("[%s] Initialization successful", _pairing_android_tv.log_id) + if await _pairing_android_tv.connect(timeout): + _LOG.debug("[%s] Connection successful", _pairing_android_tv.log_id) device_info = _pairing_android_tv.device_info or {} + _LOG.debug("[%s] Retrieved device info: %s", _pairing_android_tv.log_id, device_info) + if config.devices.assign_default_certs_to_device(_pairing_android_tv.identifier, True): res = ucapi.StatusCodes.OK - _pairing_android_tv.disconnect() + _LOG.debug("[%s] Default certificates assigned successfully", _pairing_android_tv.log_id) + + _pairing_android_tv.disconnect() + _LOG.debug("[%s] Disconnected after retrieving device information", _pairing_android_tv.log_id) if res != ucapi.StatusCodes.OK: state = _pairing_android_tv.state @@ -562,19 +735,76 @@ async def handle_user_data_pin(msg: UserDataResponse) -> SetupComplete | SetupEr id=_pairing_android_tv.identifier, name=_pairing_android_tv.name, address=_pairing_android_tv.address, - use_external_metadata=_use_external_metadata, - use_chromecast=_use_chromecast, manufacturer=device_info.get("manufacturer", ""), model=device_info.get("model", ""), + use_external_metadata=_use_external_metadata, + use_chromecast=_use_chromecast, + use_adb=_use_adb ) + _LOG.debug("Created AtvDevice: %s", device) + + config.devices.add_or_update(device) + _LOG.debug("Device added/updated in configuration") - config.devices.add_or_update(device) # triggers AndroidTv instance creation config.devices.store() + _LOG.debug("Configuration stored") - # ATV device connection will be triggered with subscribe_entities request - _pairing_android_tv = None await asyncio.sleep(1) _LOG.info("[%s] Setup successfully completed for %s", device.name, device.id) + + _pairing_android_tv = None + + return SetupComplete() + +# +# async def handle_app_selection(msg: UserDataResponse) -> SetupAction: +# global _pairing_android_tv, _adb_device_id +# from adb_tv import get_installed_apps_combined +# +# +# _setup_step = SetupSteps.APP_SELECTION +# ip_address = _pairing_android_tv.address +# device_id = _pairing_android_tv.identifier +# +# +# apps = await get_installed_apps_combined(device_id, ip_address) +# +# return RequestUserInput( +# {"en": "Select visible apps"}, +# [ +# { +# "id": "visible_apps", +# "label": {"en": "Choose apps to show"}, +# "field": { +# "multichoice": { +# "items": [{"id": app, "label": {"en": app}} for app in sorted(apps)], +# "value": [], +# } +# }, +# } +# ], +# ) +# +async def _handle_app_selection(msg: UserDataResponse) -> SetupComplete | SetupError: + from pathlib import Path + import json + + app_ids = msg.input_values.get("visible_apps", []) + if not isinstance(app_ids, list): + return SetupError() + + config_root = Path(config.devices.config_root()) # assuming your config exposes this + apps_file = config_root / "apps.json" + try: + apps_file.write_text(json.dumps(app_ids, indent=2)) + except Exception as e: + _LOG.error("Failed to write selected apps: %s", e) + return SetupError() + + _LOG.info("App selection stored: %s", app_ids) + + await handle_setup_completion() + _pairing_android_tv = None return SetupComplete() @@ -596,10 +826,12 @@ async def _handle_device_reconfigure( use_chromecast = msg.input_values.get("chromecast", "false") == "true" use_external_metadata = msg.input_values.get("external_metadata", "false") == "true" + use_adb = msg.input_values.get("adb", "false") == "true" _LOG.debug("User has changed configuration") _reconfigured_device.use_chromecast = use_chromecast _reconfigured_device.use_external_metadata = use_external_metadata + _reconfigured_device.use_adb = use_adb config.devices.add_or_update(_reconfigured_device) # triggers ATV instance update await asyncio.sleep(1) From 3ef5ba70e82a149e49e757946d0aa56caf951ccc Mon Sep 17 00:00:00 2001 From: Thomas Mason Date: Tue, 29 Apr 2025 16:31:09 +0100 Subject: [PATCH 09/46] updates --- src/setup_flow.py | 80 ++++++++++++++++++++++++----------------------- 1 file changed, 41 insertions(+), 39 deletions(-) diff --git a/src/setup_flow.py b/src/setup_flow.py index eddc6a3..0a98cd4 100644 --- a/src/setup_flow.py +++ b/src/setup_flow.py @@ -12,6 +12,7 @@ from adb_shell.auth.sign_pythonrsa import PythonRSASigner from adb_shell.auth.keygen import keygen import os +from pathlib import Path import ucapi from ucapi import ( @@ -661,49 +662,46 @@ async def handle_user_data_pin(msg: UserDataResponse) -> RequestUserInput | Setu return SetupError(error_type=IntegrationSetupError.AUTHORIZATION_ERROR) _LOG.debug("ADB authorisation confirmed") - from apps import Apps + from apps import Apps + if _use_adb: adb_apps = await get_installed_apps(adb_device) # dict[str, dict[str, str]] all_apps = {**Apps, **adb_apps} # ADB apps override Apps if same name - - - _LOG.debug("Retrieved apps: %s", all_apps) await adb_device.close() + else: + all_apps = Apps - _setup_step = SetupSteps.APP_SELECTION - return RequestUserInput( - title={ - "en": "Select visible apps", - "de": "Wähle sichtbare Apps", - "fr": "Sélectionnez les applications visibles", - }, - settings=[ - { - "id": "visible_apps", - "label": { - "en": "Choose apps to show", - "de": "Wähle Apps zur Anzeige", - "fr": "Choisir les applications à afficher", - }, - "field": { - "multichoice": { - "items": [ - { - "id": package, - "label": {"en": details.get("name", package)} - } - for package, details in sorted(all_apps.items()) - ], - "value": [], - } - }, - } - ], - ) + _LOG.debug("Retrieved apps: %s", all_apps) - else: - _LOG.debug("ADB is not enabled, skipping to setup completion") - return await handle_setup_completion(res) + _setup_step = SetupSteps.APP_SELECTION + return RequestUserInput( + title={ + "en": "Select visible apps", + "de": "Wähle sichtbare Apps", + "fr": "Sélectionnez les applications visibles", + }, + settings=[ + { + "id": "visible_apps", + "label": { + "en": "Choose apps to show", + "de": "Wähle Apps zur Anzeige", + "fr": "Choisir les applications à afficher", + }, + "field": { + "multichoice": { + "items": [ + { + "id": 'test', + "label": {"en": 'test'} + } + ], + "value": [], + } + }, + } + ], + ) async def handle_setup_completion(res) -> SetupComplete: global _pairing_android_tv @@ -785,6 +783,11 @@ async def handle_setup_completion(res) -> SetupComplete: # ], # ) # +def _get_config_root() -> Path: + config_home = Path(os.environ.get("UC_CONFIG_HOME", "./config")) + config_home.mkdir(parents=True, exist_ok=True) + return config_home + async def _handle_app_selection(msg: UserDataResponse) -> SetupComplete | SetupError: from pathlib import Path import json @@ -793,8 +796,7 @@ async def _handle_app_selection(msg: UserDataResponse) -> SetupComplete | SetupE if not isinstance(app_ids, list): return SetupError() - config_root = Path(config.devices.config_root()) # assuming your config exposes this - apps_file = config_root / "apps.json" + apps_file = _get_config_root() / "apps.json" try: apps_file.write_text(json.dumps(app_ids, indent=2)) except Exception as e: From 17c2690adef2dcfda4f794c1fa1bea0d2cdeb9af Mon Sep 17 00:00:00 2001 From: Thomas Mason Date: Tue, 29 Apr 2025 16:43:15 +0100 Subject: [PATCH 10/46] update --- src/setup_flow.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/setup_flow.py b/src/setup_flow.py index 0a98cd4..64a221e 100644 --- a/src/setup_flow.py +++ b/src/setup_flow.py @@ -695,6 +695,11 @@ async def handle_user_data_pin(msg: UserDataResponse) -> RequestUserInput | Setu "id": 'test', "label": {"en": 'test'} } + # { + # "id": package, + # "label": {"en": details.get("name", package)} + # } + # for package, details in sorted(all_apps.items()) ], "value": [], } From 95ecaafc958b739fb237caf6c5b4da96e466afbc Mon Sep 17 00:00:00 2001 From: Thomas Mason Date: Tue, 29 Apr 2025 17:15:50 +0100 Subject: [PATCH 11/46] lint --- src/adb_tv.py | 9 +++++---- src/setup_flow.py | 25 ++++++++++++++----------- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/src/adb_tv.py b/src/adb_tv.py index ed8d93c..0eaa2f3 100644 --- a/src/adb_tv.py +++ b/src/adb_tv.py @@ -44,14 +44,13 @@ async def adb_connect(device_id: str, host: str, port: int = 5555) -> Optional[A print(f"ADB connection failed to {host}:{port} — {e}") return None + async def get_installed_apps(device: AdbDeviceTcpAsync) -> Dict[str, Dict[str, str]]: """Retrieve list of installed non-system apps in structured format.""" output = await device.shell("pm list packages -3 -e") packages = sorted(line.replace("package:", "").strip() for line in output.splitlines()) - return { - package: {"url": f"market://launch?id={package}"} - for package in packages - } + return {package: {"url": f"market://launch?id={package}"} for package in packages} + async def is_authorised(device: AdbDeviceTcpAsync) -> bool: try: @@ -60,6 +59,7 @@ async def is_authorised(device: AdbDeviceTcpAsync) -> bool: except Exception: return False + async def test_connection(device_id: str, host: str) -> None: device = await adb_connect(device_id, host) if not device: @@ -77,6 +77,7 @@ async def test_connection(device_id: str, host: str) -> None: if __name__ == "__main__": import sys + if len(sys.argv) != 3: print("Usage: python adb_tv.py ") else: diff --git a/src/setup_flow.py b/src/setup_flow.py index 64a221e..7f8cff9 100644 --- a/src/setup_flow.py +++ b/src/setup_flow.py @@ -47,6 +47,7 @@ class SetupSteps(IntEnum): APP_SELECTION = 6 RECONFIGURE = 7 + _setup_step = SetupSteps.INIT _cfg_add_device: bool = False _discovered_android_tvs: list[dict[str, str]] = [] @@ -344,7 +345,8 @@ async def handle_configuration_mode( "fr": "Fonctionnalité en aperçu: Activer la connexion ADB (pour la navigation dans les applications)", }, "field": {"checkbox": {"value": use_adb}}, - }, ], + }, + ], ) case "reset": config.devices.clear() # triggers device instance removal @@ -610,6 +612,7 @@ async def handle_device_choice(msg: UserDataResponse) -> RequestUserInput | Setu import logging + async def handle_user_data_pin(msg: UserDataResponse) -> RequestUserInput | SetupComplete | SetupError: """ Process user data pairing pin response in a setup process. @@ -691,15 +694,12 @@ async def handle_user_data_pin(msg: UserDataResponse) -> RequestUserInput | Setu "field": { "multichoice": { "items": [ - { - "id": 'test', - "label": {"en": 'test'} - } - # { - # "id": package, - # "label": {"en": details.get("name", package)} - # } - # for package, details in sorted(all_apps.items()) + {"id": "test", "label": {"en": "test"}} + # { + # "id": package, + # "label": {"en": details.get("name", package)} + # } + # for package, details in sorted(all_apps.items()) ], "value": [], } @@ -708,6 +708,7 @@ async def handle_user_data_pin(msg: UserDataResponse) -> RequestUserInput | Setu ], ) + async def handle_setup_completion(res) -> SetupComplete: global _pairing_android_tv @@ -742,7 +743,7 @@ async def handle_setup_completion(res) -> SetupComplete: model=device_info.get("model", ""), use_external_metadata=_use_external_metadata, use_chromecast=_use_chromecast, - use_adb=_use_adb + use_adb=_use_adb, ) _LOG.debug("Created AtvDevice: %s", device) @@ -759,6 +760,7 @@ async def handle_setup_completion(res) -> SetupComplete: return SetupComplete() + # # async def handle_app_selection(msg: UserDataResponse) -> SetupAction: # global _pairing_android_tv, _adb_device_id @@ -793,6 +795,7 @@ def _get_config_root() -> Path: config_home.mkdir(parents=True, exist_ok=True) return config_home + async def _handle_app_selection(msg: UserDataResponse) -> SetupComplete | SetupError: from pathlib import Path import json From ced371e368aa8c0de9be98a4569fc8f3525d42ed Mon Sep 17 00:00:00 2001 From: Thomas Mason Date: Tue, 29 Apr 2025 17:16:22 +0100 Subject: [PATCH 12/46] isort --- src/setup_flow.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/setup_flow.py b/src/setup_flow.py index 7f8cff9..266e854 100644 --- a/src/setup_flow.py +++ b/src/setup_flow.py @@ -7,14 +7,14 @@ import asyncio import logging -from enum import IntEnum -from adb_shell.adb_device_async import AdbDeviceTcpAsync -from adb_shell.auth.sign_pythonrsa import PythonRSASigner -from adb_shell.auth.keygen import keygen import os +from enum import IntEnum from pathlib import Path import ucapi +from adb_shell.adb_device_async import AdbDeviceTcpAsync +from adb_shell.auth.keygen import keygen +from adb_shell.auth.sign_pythonrsa import PythonRSASigner from ucapi import ( AbortDriverSetup, DriverSetupRequest, @@ -27,11 +27,11 @@ UserDataResponse, ) +import adb_tv import config import discover import tv from config import AtvDevice -import adb_tv _LOG = logging.getLogger(__name__) @@ -651,7 +651,7 @@ async def handle_user_data_pin(msg: UserDataResponse) -> RequestUserInput | Setu _LOG.error("ADB setup failed: 'use_adb' not found in input values") return SetupError() - from adb_tv import adb_connect, is_authorised, get_installed_apps + from adb_tv import adb_connect, get_installed_apps, is_authorised device_id = _pairing_android_tv.identifier ip_address = _pairing_android_tv.address @@ -797,8 +797,8 @@ def _get_config_root() -> Path: async def _handle_app_selection(msg: UserDataResponse) -> SetupComplete | SetupError: - from pathlib import Path import json + from pathlib import Path app_ids = msg.input_values.get("visible_apps", []) if not isinstance(app_ids, list): From 9573f727fdc4d5a3c8e16b8831da48bf5c649e1f Mon Sep 17 00:00:00 2001 From: Thomas Mason Date: Wed, 30 Apr 2025 13:41:36 +0100 Subject: [PATCH 13/46] update setupflow to allow for application list selection and if adb also setting friendly names --- src/setup_flow.py | 75 ++++++++++++++++++++++++++++++----------------- 1 file changed, 48 insertions(+), 27 deletions(-) diff --git a/src/setup_flow.py b/src/setup_flow.py index 266e854..7572b32 100644 --- a/src/setup_flow.py +++ b/src/setup_flow.py @@ -669,12 +669,56 @@ async def handle_user_data_pin(msg: UserDataResponse) -> RequestUserInput | Setu if _use_adb: adb_apps = await get_installed_apps(adb_device) # dict[str, dict[str, str]] - all_apps = {**Apps, **adb_apps} # ADB apps override Apps if same name + offline_apps = {**Apps, **adb_apps} # ADB apps override Apps if same name await adb_device.close() else: - all_apps = Apps + offline_apps = Apps - _LOG.debug("Retrieved apps: %s", all_apps) + _LOG.debug("Retrieved offline apps: %s", offline_apps) + _LOG.debug("Retrieved ADB apps: %s", adb_apps) + + settings = [] + + for package, details in sorted(offline_apps.items()): + name = details.get("name", package) + + if _use_adb and package in adb_apps: + # ADB-specific apps: show both friendly name input and enable checkbox + settings.append( + { + "id": f"{package}_enabled", + "label": { + "en": f"Enable {package}", + "de": f"Aktiviere {package}", + "fr": f"Activer {package}", + }, + "field": {"checkbox": {"value": True}}, + } + ) + settings.append( + { + "id": f"{package}_name", + "label": { + "en": f"Friendly name for {package}", + "de": f"Anzeigename für {package}", + "fr": f"Nom convivial pour {package}", + }, + "field": {"text": {"value": name}}, + } + ) + else: + # Predefined apps: only checkbox + settings.append( + { + "id": f"{package}_enabled", + "label": { + "en": name, + "de": name, + "fr": name, + }, + "field": {"checkbox": {"value": True}}, + } + ) _setup_step = SetupSteps.APP_SELECTION return RequestUserInput( @@ -683,32 +727,9 @@ async def handle_user_data_pin(msg: UserDataResponse) -> RequestUserInput | Setu "de": "Wähle sichtbare Apps", "fr": "Sélectionnez les applications visibles", }, - settings=[ - { - "id": "visible_apps", - "label": { - "en": "Choose apps to show", - "de": "Wähle Apps zur Anzeige", - "fr": "Choisir les applications à afficher", - }, - "field": { - "multichoice": { - "items": [ - {"id": "test", "label": {"en": "test"}} - # { - # "id": package, - # "label": {"en": details.get("name", package)} - # } - # for package, details in sorted(all_apps.items()) - ], - "value": [], - } - }, - } - ], + settings=settings, ) - async def handle_setup_completion(res) -> SetupComplete: global _pairing_android_tv From ab015f78c7d1a88dbd425798c841a1ba52a14e8d Mon Sep 17 00:00:00 2001 From: Thomas Mason Date: Wed, 30 Apr 2025 20:59:12 +0100 Subject: [PATCH 14/46] testing --- src/setup_flow.py | 133 ++++++++-------------------------------------- 1 file changed, 21 insertions(+), 112 deletions(-) diff --git a/src/setup_flow.py b/src/setup_flow.py index 7572b32..5b8d158 100644 --- a/src/setup_flow.py +++ b/src/setup_flow.py @@ -118,7 +118,7 @@ async def driver_setup_handler(msg: SetupDriver) -> SetupAction: if _setup_step == SetupSteps.PAIRING_PIN and "pin" in msg.input_values: return await handle_user_data_pin(msg) if _setup_step == SetupSteps.APP_SELECTION and "app_selection" in msg.input_values: - return await _handle_app_selection(msg) + return await handle_app_selection(msg) if _setup_step == SetupSteps.RECONFIGURE: return await _handle_device_reconfigure(msg) _LOG.error("No or invalid user response was received: %s", msg) @@ -548,71 +548,6 @@ async def handle_device_choice(msg: UserDataResponse) -> RequestUserInput | Setu return _setup_error_from_device_state(_pairing_android_tv.state) - -# async def handle_user_data_pin(msg: UserDataResponse) -> SetupComplete | SetupError: -# """ -# Process user data pairing pin response in a setup process. -# -# Driver setup callback to provide requested user data during the setup process. -# -# :param msg: response data from the requested user data -# :return: the setup action on how to continue: SetupComplete if a valid Android TV device was chosen. -# """ -# global _pairing_android_tv -# -# if _pairing_android_tv is None: -# _LOG.error("Can't handle pairing pin: no device instance! Aborting setup") -# return SetupError() -# -# _LOG.info("[%s] User has entered the PIN", _pairing_android_tv.log_id) -# -# res = await _pairing_android_tv.finish_pairing(msg.input_values["pin"]) -# _pairing_android_tv.disconnect() -# -# device_info = None -# -# # Connect again to retrieve device identifier (with init()) and additional device information (with connect()) -# if res == ucapi.StatusCodes.OK: -# _LOG.info( -# "[%s] Pairing done, retrieving device information", -# _pairing_android_tv.log_id, -# ) -# res = ucapi.StatusCodes.SERVER_ERROR -# timeout = int(tv.CONNECTION_TIMEOUT) -# if await _pairing_android_tv.init(timeout) and await _pairing_android_tv.connect(timeout): -# device_info = _pairing_android_tv.device_info or {} -# if config.devices.assign_default_certs_to_device(_pairing_android_tv.identifier, True): -# res = ucapi.StatusCodes.OK -# _pairing_android_tv.disconnect() -# -# if res != ucapi.StatusCodes.OK: -# state = _pairing_android_tv.state -# _LOG.info("[%s] Setup failed: %s (state=%s)", _pairing_android_tv.log_id, res, state) -# _pairing_android_tv = None -# return _setup_error_from_device_state(state) -# -# device = AtvDevice( -# id=_pairing_android_tv.identifier, -# name=_pairing_android_tv.name, -# address=_pairing_android_tv.address, -# use_external_metadata=_use_external_metadata, -# use_chromecast=_use_chromecast, -# manufacturer=device_info.get("manufacturer", ""), -# model=device_info.get("model", ""), -# ) -# -# config.devices.add_or_update(device) # triggers AndroidTv instance creation -# config.devices.store() -# -# # ATV device connection will be triggered with subscribe_entities request -# _pairing_android_tv = None -# await asyncio.sleep(1) -# _LOG.info("[%s] Setup successfully completed for %s", device.name, device.id) -# return SetupComplete() - -import logging - - async def handle_user_data_pin(msg: UserDataResponse) -> RequestUserInput | SetupComplete | SetupError: """ Process user data pairing pin response in a setup process. @@ -670,12 +605,12 @@ async def handle_user_data_pin(msg: UserDataResponse) -> RequestUserInput | Setu if _use_adb: adb_apps = await get_installed_apps(adb_device) # dict[str, dict[str, str]] offline_apps = {**Apps, **adb_apps} # ADB apps override Apps if same name + _LOG.debug("Retrieved ADB apps: %s", adb_apps) await adb_device.close() else: offline_apps = Apps _LOG.debug("Retrieved offline apps: %s", offline_apps) - _LOG.debug("Retrieved ADB apps: %s", adb_apps) settings = [] @@ -779,66 +714,40 @@ async def handle_setup_completion(res) -> SetupComplete: _pairing_android_tv = None - return SetupComplete() - - -# -# async def handle_app_selection(msg: UserDataResponse) -> SetupAction: -# global _pairing_android_tv, _adb_device_id -# from adb_tv import get_installed_apps_combined -# -# -# _setup_step = SetupSteps.APP_SELECTION -# ip_address = _pairing_android_tv.address -# device_id = _pairing_android_tv.identifier -# -# -# apps = await get_installed_apps_combined(device_id, ip_address) -# -# return RequestUserInput( -# {"en": "Select visible apps"}, -# [ -# { -# "id": "visible_apps", -# "label": {"en": "Choose apps to show"}, -# "field": { -# "multichoice": { -# "items": [{"id": app, "label": {"en": app}} for app in sorted(apps)], -# "value": [], -# } -# }, -# } -# ], -# ) -# def _get_config_root() -> Path: config_home = Path(os.environ.get("UC_CONFIG_HOME", "./config")) config_home.mkdir(parents=True, exist_ok=True) return config_home -async def _handle_app_selection(msg: UserDataResponse) -> SetupComplete | SetupError: +async def handle_app_selection(msg: UserDataResponse) -> SetupComplete | SetupError: import json - from pathlib import Path + from apps import Apps # Static apps - app_ids = msg.input_values.get("visible_apps", []) - if not isinstance(app_ids, list): - return SetupError() + selected_apps = {} + + for field_id, value in msg.input_values.items(): + if field_id.endswith("_enabled") and value: + package = field_id.removesuffix("_enabled") + name_field = f"{package}_name" + friendly_name = msg.input_values.get(name_field, package) + + # Prefer static app URL if it exists + static_entry = Apps.get(friendly_name) or Apps.get(package) + url = static_entry["url"] if static_entry else f"market://launch?id={package}" + + selected_apps[friendly_name] = {"url": url} apps_file = _get_config_root() / "apps.json" try: - apps_file.write_text(json.dumps(app_ids, indent=2)) + apps_file.write_text(json.dumps(selected_apps, indent=2)) + _LOG.info("App selection stored: %s", selected_apps) + handle_setup_completion() + return SetupComplete() except Exception as e: _LOG.error("Failed to write selected apps: %s", e) return SetupError() - _LOG.info("App selection stored: %s", app_ids) - - await handle_setup_completion() - _pairing_android_tv = None - return SetupComplete() - - async def _handle_device_reconfigure( msg: UserDataResponse, ) -> SetupComplete | SetupError: From 4ead16b199e0950dab79dda8ff55ecd9e7c2327d Mon Sep 17 00:00:00 2001 From: Thomas Mason Date: Wed, 30 Apr 2025 22:36:01 +0100 Subject: [PATCH 15/46] final commit - adding in apps configurator + adb + cleaning up adb keys and appslist.json file --- src/config.py | 13 +++++ src/setup_flow.py | 135 ++++++++++++++++++++++++++-------------------- src/tv.py | 20 ++++++- 3 files changed, 107 insertions(+), 61 deletions(-) diff --git a/src/config.py b/src/config.py index f2e1110..28305f9 100644 --- a/src/config.py +++ b/src/config.py @@ -12,11 +12,24 @@ import os from dataclasses import dataclass from typing import Iterator +from pathlib import Path _LOG = logging.getLogger(__name__) _CFG_FILENAME = "config.json" +# Paths +def _get_config_root() -> Path: + config_home = Path(os.environ.get("UC_CONFIG_HOME", "./config")) + config_home.mkdir(parents=True, exist_ok=True) + return config_home + + +def _get_cache_root() -> Path: + data_home = Path(os.environ.get("UC_DATA_HOME", "./data")) + cache_root = data_home / CACHE_ROOT + cache_root.mkdir(parents=True, exist_ok=True) + return cache_root @dataclass class AtvDevice: diff --git a/src/setup_flow.py b/src/setup_flow.py index 5b8d158..cd20a55 100644 --- a/src/setup_flow.py +++ b/src/setup_flow.py @@ -44,8 +44,8 @@ class SetupSteps(IntEnum): DISCOVER = 2 DEVICE_CHOICE = 3 PAIRING_PIN = 4 - APP_SELECTION = 6 - RECONFIGURE = 7 + APP_SELECTION = 5 + RECONFIGURE = 6 _setup_step = SetupSteps.INIT @@ -57,6 +57,7 @@ class SetupSteps(IntEnum): _use_chromecast: bool = False _use_adb: bool = False _adb_device_id: str = "" +_device_info: dict[str, str] = {} # TODO #9 externalize language texts _user_input_discovery = RequestUserInput( @@ -117,7 +118,7 @@ async def driver_setup_handler(msg: SetupDriver) -> SetupAction: return await handle_device_choice(msg) if _setup_step == SetupSteps.PAIRING_PIN and "pin" in msg.input_values: return await handle_user_data_pin(msg) - if _setup_step == SetupSteps.APP_SELECTION and "app_selection" in msg.input_values: + if _setup_step == SetupSteps.APP_SELECTION: return await handle_app_selection(msg) if _setup_step == SetupSteps.RECONFIGURE: return await _handle_device_reconfigure(msg) @@ -296,6 +297,16 @@ async def handle_configuration_mode( _LOG.warning("Could not remove device from configuration: %s", choice) return SetupError(error_type=IntegrationSetupError.OTHER) config.devices.store() + adb_cert_path = Path(os.environ.get("UC_CONFIG_HOME", "./config")) / "certs" / f"adb_{choice}" + adb_cert_path_pub = Path(os.environ.get("UC_CONFIG_HOME", "./config")) / "certs" / f"adb_{choice}.pub" + if adb_cert_path.exists(): + adb_cert_path.unlink() + if adb_cert_path_pub.exists(): + adb_cert_path_pub.unlink() + appslist_path = Path(os.environ.get("UC_CONFIG_HOME", "./config")) / f"appslist_{choice}.json" + if appslist_path.exists(): + appslist_path.unlink() + _LOG.info("Device removed from configuration: %s", choice) return SetupComplete() case "configure": # Reconfigure device if the identifier has changed @@ -558,6 +569,7 @@ async def handle_user_data_pin(msg: UserDataResponse) -> RequestUserInput | Setu :return: the setup action on how to continue: SetupComplete if a valid Android TV device was chosen. """ global _pairing_android_tv + global _setup_step _LOG.debug("Entered handle_user_data_pin with msg: %s", msg) @@ -579,6 +591,29 @@ async def handle_user_data_pin(msg: UserDataResponse) -> RequestUserInput | Setu timeout = int(tv.CONNECTION_TIMEOUT) _LOG.debug("[%s] Attempting to initialize and connect with timeout: %d", _pairing_android_tv.log_id, timeout) + _device_info = None + timeout = int(tv.CONNECTION_TIMEOUT) + + if await _pairing_android_tv.init(timeout): + _LOG.debug("[%s] Initialization successful", _pairing_android_tv.log_id) + if await _pairing_android_tv.connect(timeout): + _LOG.debug("[%s] Connection successful", _pairing_android_tv.log_id) + _device_info = _pairing_android_tv.device_info or {} + _LOG.debug("[%s] Retrieved device info: %s", _pairing_android_tv.log_id, _device_info) + + if config.devices.assign_default_certs_to_device(_pairing_android_tv.identifier, True): + res = ucapi.StatusCodes.OK + _LOG.debug("[%s] Default certificates assigned successfully", _pairing_android_tv.log_id) + + _pairing_android_tv.disconnect() + _LOG.debug("[%s] Disconnected after retrieving device information", _pairing_android_tv.log_id) + + if res != ucapi.StatusCodes.OK: + state = _pairing_android_tv.state + _LOG.info("[%s] Setup failed: %s (state=%s)", _pairing_android_tv.log_id, res, state) + _pairing_android_tv = None + return _setup_error_from_device_state(state) + if _use_adb: _LOG.debug("ADB is enabled, proceeding with ADB setup") @@ -627,7 +662,7 @@ async def handle_user_data_pin(msg: UserDataResponse) -> RequestUserInput | Setu "de": f"Aktiviere {package}", "fr": f"Activer {package}", }, - "field": {"checkbox": {"value": True}}, + "field": {"checkbox": {"value": False}}, } ) settings.append( @@ -651,7 +686,7 @@ async def handle_user_data_pin(msg: UserDataResponse) -> RequestUserInput | Setu "de": name, "fr": name, }, - "field": {"checkbox": {"value": True}}, + "field": {"checkbox": {"value": False}}, } ) @@ -665,55 +700,6 @@ async def handle_user_data_pin(msg: UserDataResponse) -> RequestUserInput | Setu settings=settings, ) -async def handle_setup_completion(res) -> SetupComplete: - global _pairing_android_tv - - device_info = None - timeout = int(tv.CONNECTION_TIMEOUT) - - if await _pairing_android_tv.init(timeout): - _LOG.debug("[%s] Initialization successful", _pairing_android_tv.log_id) - if await _pairing_android_tv.connect(timeout): - _LOG.debug("[%s] Connection successful", _pairing_android_tv.log_id) - device_info = _pairing_android_tv.device_info or {} - _LOG.debug("[%s] Retrieved device info: %s", _pairing_android_tv.log_id, device_info) - - if config.devices.assign_default_certs_to_device(_pairing_android_tv.identifier, True): - res = ucapi.StatusCodes.OK - _LOG.debug("[%s] Default certificates assigned successfully", _pairing_android_tv.log_id) - - _pairing_android_tv.disconnect() - _LOG.debug("[%s] Disconnected after retrieving device information", _pairing_android_tv.log_id) - - if res != ucapi.StatusCodes.OK: - state = _pairing_android_tv.state - _LOG.info("[%s] Setup failed: %s (state=%s)", _pairing_android_tv.log_id, res, state) - _pairing_android_tv = None - return _setup_error_from_device_state(state) - - device = AtvDevice( - id=_pairing_android_tv.identifier, - name=_pairing_android_tv.name, - address=_pairing_android_tv.address, - manufacturer=device_info.get("manufacturer", ""), - model=device_info.get("model", ""), - use_external_metadata=_use_external_metadata, - use_chromecast=_use_chromecast, - use_adb=_use_adb, - ) - _LOG.debug("Created AtvDevice: %s", device) - - config.devices.add_or_update(device) - _LOG.debug("Device added/updated in configuration") - - config.devices.store() - _LOG.debug("Configuration stored") - - await asyncio.sleep(1) - _LOG.info("[%s] Setup successfully completed for %s", device.name, device.id) - - _pairing_android_tv = None - def _get_config_root() -> Path: config_home = Path(os.environ.get("UC_CONFIG_HOME", "./config")) config_home.mkdir(parents=True, exist_ok=True) @@ -721,13 +707,19 @@ def _get_config_root() -> Path: async def handle_app_selection(msg: UserDataResponse) -> SetupComplete | SetupError: + global _pairing_android_tv + global _use_chromecast + global _use_external_metadata + global _use_adb + global _device_info + import json - from apps import Apps # Static apps + from apps import Apps # offline apps selected_apps = {} for field_id, value in msg.input_values.items(): - if field_id.endswith("_enabled") and value: + if field_id.endswith("_enabled") and str(value).lower() == "true": package = field_id.removesuffix("_enabled") name_field = f"{package}_name" friendly_name = msg.input_values.get(name_field, package) @@ -738,16 +730,41 @@ async def handle_app_selection(msg: UserDataResponse) -> SetupComplete | SetupEr selected_apps[friendly_name] = {"url": url} - apps_file = _get_config_root() / "apps.json" + filename = f"appslist_{_pairing_android_tv.identifier}.json" + apps_file = _get_config_root() / filename try: apps_file.write_text(json.dumps(selected_apps, indent=2)) _LOG.info("App selection stored: %s", selected_apps) - handle_setup_completion() - return SetupComplete() except Exception as e: _LOG.error("Failed to write selected apps: %s", e) return SetupError() + + device = AtvDevice( + id=_pairing_android_tv.identifier, + name=_pairing_android_tv.name, + address=_pairing_android_tv.address, + manufacturer=_device_info.get("manufacturer", ""), + model=_device_info.get("model", ""), + use_external_metadata=_use_external_metadata, + use_chromecast=_use_chromecast, + use_adb=_use_adb, + ) + _LOG.debug("Created AtvDevice: %s", device) + + config.devices.add_or_update(device) + _LOG.debug("Device added/updated in configuration") + + config.devices.store() + _LOG.debug("Configuration stored") + + await asyncio.sleep(1) + _LOG.info("[%s] Setup successfully completed for %s", device.name, device.id) + + _pairing_android_tv = None + + return SetupComplete() + async def _handle_device_reconfigure( msg: UserDataResponse, ) -> SetupComplete | SetupError: diff --git a/src/tv.py b/src/tv.py index fa3051d..89672c4 100644 --- a/src/tv.py +++ b/src/tv.py @@ -54,6 +54,9 @@ from profiles import KeyPress, Profile from util import filter_data_img_properties +import json +from config import _get_config_root + _LOG = logging.getLogger(__name__) CONNECTION_TIMEOUT: float = 10.0 @@ -716,8 +719,21 @@ def _is_available_updated(self, is_available: bool): def _update_app_list(self) -> None: update = {} source_list = [] - for app in apps.Apps: - source_list.append(app) + + filename = f"appslist_{self._identifier}.json" + apps_file = _get_config_root() / filename + + if apps_file.exists(): + try: + with apps_file.open("r", encoding="utf-8") as f: + selected_apps = json.load(f) + source_list.extend(selected_apps.keys()) + except Exception as e: + _LOG.warning("Failed to read apps list from %s: %s", apps_file, e) + else: + _LOG.info("No saved app list found for %s, falling back to default", self._identifier) + import apps # fall back to static apps + source_list.extend(apps.Apps.keys()) update[MediaAttr.SOURCE_LIST] = source_list self.events.emit(Events.UPDATE, self._identifier, update) From fdefbfb8e4c4bf90878469d6e063a75e8eed379a Mon Sep 17 00:00:00 2001 From: Thomas Mason Date: Wed, 30 Apr 2025 22:54:17 +0100 Subject: [PATCH 16/46] using idmapping if friendly names are already known + fixes --- src/setup_flow.py | 12 ++++++++---- src/tv.py | 22 +++++++++++++++++++--- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/src/setup_flow.py b/src/setup_flow.py index cd20a55..cd5e59b 100644 --- a/src/setup_flow.py +++ b/src/setup_flow.py @@ -635,7 +635,8 @@ async def handle_user_data_pin(msg: UserDataResponse) -> RequestUserInput | Setu return SetupError(error_type=IntegrationSetupError.AUTHORIZATION_ERROR) _LOG.debug("ADB authorisation confirmed") - from apps import Apps + + from apps import Apps, IdMappings if _use_adb: adb_apps = await get_installed_apps(adb_device) # dict[str, dict[str, str]] @@ -650,10 +651,13 @@ async def handle_user_data_pin(msg: UserDataResponse) -> RequestUserInput | Setu settings = [] for package, details in sorted(offline_apps.items()): - name = details.get("name", package) + # Determine default friendly name + mapped_name = IdMappings.get(package) + static_name = details.get("name", package) + name = mapped_name or static_name if _use_adb and package in adb_apps: - # ADB-specific apps: show both friendly name input and enable checkbox + # ADB-specific apps: show checkbox + pre-filled friendly name input settings.append( { "id": f"{package}_enabled", @@ -677,7 +681,7 @@ async def handle_user_data_pin(msg: UserDataResponse) -> RequestUserInput | Setu } ) else: - # Predefined apps: only checkbox + # Static apps: checkbox only settings.append( { "id": f"{package}_enabled", diff --git a/src/tv.py b/src/tv.py index 89672c4..5d3fe6a 100644 --- a/src/tv.py +++ b/src/tv.py @@ -788,15 +788,31 @@ async def turn_off(self) -> ucapi.StatusCodes: async def select_source(self, source: str) -> ucapi.StatusCodes: """ - Select a given source, either a pre-defined app, input or by app-link/id. + Select a given source, either a user-defined app (from JSON), + an input source (KeyCode), or directly by app-link / id. :param source: the friendly source name or an app-link / id """ - if source in apps.Apps: - return await self._launch_app(apps.Apps[source]["url"]) + # Load saved apps for this device + apps_file = _get_config_root() / f"appslist_{self._identifier}.json" + apps_list = {} + + if apps_file.exists(): + try: + with apps_file.open("r", encoding="utf-8") as f: + apps_list = json.load(f) + except Exception as e: + _LOG.warning("Failed to read apps list for %s: %s", self._identifier, e) + + # Match known friendly app name + if source in apps_list: + return await self._launch_app(apps_list[source]["url"]) + + # Match input source if source in inputs.KeyCode: return await self._switch_input(source) + # Fall back to direct launch (e.g. package name or intent URI) return await self._launch_app(source) @async_handle_atvlib_errors From ca21a789c0f1a3a780807b7b09661ae89c7a76d1 Mon Sep 17 00:00:00 2001 From: Thomas Mason Date: Wed, 30 Apr 2025 23:02:28 +0100 Subject: [PATCH 17/46] fixes --- src/setup_flow.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/setup_flow.py b/src/setup_flow.py index cd5e59b..1093b3e 100644 --- a/src/setup_flow.py +++ b/src/setup_flow.py @@ -637,6 +637,7 @@ async def handle_user_data_pin(msg: UserDataResponse) -> RequestUserInput | Setu _LOG.debug("ADB authorisation confirmed") from apps import Apps, IdMappings + from external_metadata import get_app_metadata if _use_adb: adb_apps = await get_installed_apps(adb_device) # dict[str, dict[str, str]] @@ -651,13 +652,21 @@ async def handle_user_data_pin(msg: UserDataResponse) -> RequestUserInput | Setu settings = [] for package, details in sorted(offline_apps.items()): - # Determine default friendly name + # Start with mapped name or static name mapped_name = IdMappings.get(package) static_name = details.get("name", package) name = mapped_name or static_name + # Attempt to get external metadata if name is still just package id + if _use_external_metadata and not mapped_name: + try: + metadata = await get_app_metadata(package) + name = metadata.get("name", name) + except Exception as e: + _LOG.warning("Metadata lookup failed for %s: %s", package, e) + if _use_adb and package in adb_apps: - # ADB-specific apps: show checkbox + pre-filled friendly name input + # ADB-specific apps: checkbox + friendly name input settings.append( { "id": f"{package}_enabled", @@ -681,7 +690,7 @@ async def handle_user_data_pin(msg: UserDataResponse) -> RequestUserInput | Setu } ) else: - # Static apps: checkbox only + # Predefined apps: checkbox only settings.append( { "id": f"{package}_enabled", From b0ae053617ab3db6e39ced06c1cdca23be497a8c Mon Sep 17 00:00:00 2001 From: Thomas Mason Date: Wed, 30 Apr 2025 23:19:52 +0100 Subject: [PATCH 18/46] linting --- src/config.py | 4 +++- src/setup_flow.py | 5 ++++- src/tv.py | 7 +++---- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/config.py b/src/config.py index 28305f9..c3ddf3a 100644 --- a/src/config.py +++ b/src/config.py @@ -11,13 +11,14 @@ import logging import os from dataclasses import dataclass -from typing import Iterator from pathlib import Path +from typing import Iterator _LOG = logging.getLogger(__name__) _CFG_FILENAME = "config.json" + # Paths def _get_config_root() -> Path: config_home = Path(os.environ.get("UC_CONFIG_HOME", "./config")) @@ -31,6 +32,7 @@ def _get_cache_root() -> Path: cache_root.mkdir(parents=True, exist_ok=True) return cache_root + @dataclass class AtvDevice: """Android TV device configuration.""" diff --git a/src/setup_flow.py b/src/setup_flow.py index 1093b3e..4673bee 100644 --- a/src/setup_flow.py +++ b/src/setup_flow.py @@ -559,6 +559,7 @@ async def handle_device_choice(msg: UserDataResponse) -> RequestUserInput | Setu return _setup_error_from_device_state(_pairing_android_tv.state) + async def handle_user_data_pin(msg: UserDataResponse) -> RequestUserInput | SetupComplete | SetupError: """ Process user data pairing pin response in a setup process. @@ -713,6 +714,7 @@ async def handle_user_data_pin(msg: UserDataResponse) -> RequestUserInput | Setu settings=settings, ) + def _get_config_root() -> Path: config_home = Path(os.environ.get("UC_CONFIG_HOME", "./config")) config_home.mkdir(parents=True, exist_ok=True) @@ -727,6 +729,7 @@ async def handle_app_selection(msg: UserDataResponse) -> SetupComplete | SetupEr global _device_info import json + from apps import Apps # offline apps selected_apps = {} @@ -752,7 +755,6 @@ async def handle_app_selection(msg: UserDataResponse) -> SetupComplete | SetupEr _LOG.error("Failed to write selected apps: %s", e) return SetupError() - device = AtvDevice( id=_pairing_android_tv.identifier, name=_pairing_android_tv.name, @@ -778,6 +780,7 @@ async def handle_app_selection(msg: UserDataResponse) -> SetupComplete | SetupEr return SetupComplete() + async def _handle_device_reconfigure( msg: UserDataResponse, ) -> SetupComplete | SetupError: diff --git a/src/tv.py b/src/tv.py index 5d3fe6a..77b9a7e 100644 --- a/src/tv.py +++ b/src/tv.py @@ -8,6 +8,7 @@ # pylint: disable=too-many-lines import asyncio +import json import logging import os import socket @@ -49,14 +50,11 @@ import apps import discover import inputs -from config import AtvDevice +from config import AtvDevice, _get_config_root from external_metadata import encode_icon_to_data_uri, get_app_metadata from profiles import KeyPress, Profile from util import filter_data_img_properties -import json -from config import _get_config_root - _LOG = logging.getLogger(__name__) CONNECTION_TIMEOUT: float = 10.0 @@ -733,6 +731,7 @@ def _update_app_list(self) -> None: else: _LOG.info("No saved app list found for %s, falling back to default", self._identifier) import apps # fall back to static apps + source_list.extend(apps.Apps.keys()) update[MediaAttr.SOURCE_LIST] = source_list From 2b621da99e29dddcebd4f28700b7bd5cf730e10f Mon Sep 17 00:00:00 2001 From: Thomas Mason Date: Wed, 30 Apr 2025 23:30:47 +0100 Subject: [PATCH 19/46] adding in requirements.txt --- requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 9b8f0b0..9d94bb7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,4 +6,5 @@ pillow>=11.2.1 requests>=2.32 pychromecast~=14.0.7 httpx~=0.28.1 -sanitize-filename~=1.2.0 \ No newline at end of file +sanitize-filename~=1.2.0 +adb-shell==0.4.4 \ No newline at end of file From 3739dd718598add4417e1fb1819103a05ff7fae2 Mon Sep 17 00:00:00 2001 From: Thomas Mason Date: Wed, 30 Apr 2025 23:33:30 +0100 Subject: [PATCH 20/46] clean up --- src/external_metadata.py | 14 +------------- src/setup_flow.py | 9 +-------- 2 files changed, 2 insertions(+), 21 deletions(-) diff --git a/src/external_metadata.py b/src/external_metadata.py index f2c0dd1..ab60416 100644 --- a/src/external_metadata.py +++ b/src/external_metadata.py @@ -21,6 +21,7 @@ from PIL.Image import Resampling from pychromecast.controllers.media import MediaImage from sanitize_filename import sanitize +from config import _get_config_root, _get_cache_root _LOG = logging.getLogger(__name__) @@ -30,19 +31,6 @@ # Paths -def _get_config_root() -> Path: - config_home = Path(os.environ.get("UC_CONFIG_HOME", "./config")) - config_home.mkdir(parents=True, exist_ok=True) - return config_home - - -def _get_cache_root() -> Path: - data_home = Path(os.environ.get("UC_DATA_HOME", "./data")) - cache_root = data_home / CACHE_ROOT - cache_root.mkdir(parents=True, exist_ok=True) - return cache_root - - def _get_metadata_dir() -> Path: metadata_dir = _get_cache_root() metadata_dir.mkdir(parents=True, exist_ok=True) diff --git a/src/setup_flow.py b/src/setup_flow.py index 4673bee..390c881 100644 --- a/src/setup_flow.py +++ b/src/setup_flow.py @@ -31,7 +31,7 @@ import config import discover import tv -from config import AtvDevice +from config import AtvDevice, _get_config_root _LOG = logging.getLogger(__name__) @@ -714,13 +714,6 @@ async def handle_user_data_pin(msg: UserDataResponse) -> RequestUserInput | Setu settings=settings, ) - -def _get_config_root() -> Path: - config_home = Path(os.environ.get("UC_CONFIG_HOME", "./config")) - config_home.mkdir(parents=True, exist_ok=True) - return config_home - - async def handle_app_selection(msg: UserDataResponse) -> SetupComplete | SetupError: global _pairing_android_tv global _use_chromecast From a799181fb2571c9b73c58fe943936d08251536b1 Mon Sep 17 00:00:00 2001 From: Thomas Mason Date: Wed, 30 Apr 2025 23:34:41 +0100 Subject: [PATCH 21/46] clean up --- src/external_metadata.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/external_metadata.py b/src/external_metadata.py index ab60416..38f66fe 100644 --- a/src/external_metadata.py +++ b/src/external_metadata.py @@ -29,7 +29,6 @@ ICON_SUBDIR = "icons" ICON_SIZE = (240, 240) - # Paths def _get_metadata_dir() -> Path: metadata_dir = _get_cache_root() From e35a2f80e250a2e19a598d947b19516c24053d7f Mon Sep 17 00:00:00 2001 From: Thomas Mason Date: Wed, 30 Apr 2025 23:43:31 +0100 Subject: [PATCH 22/46] replacing cache_root with config _get_data_root --- src/config.py | 7 +++---- src/external_metadata.py | 6 +++--- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/config.py b/src/config.py index c3ddf3a..d1ca887 100644 --- a/src/config.py +++ b/src/config.py @@ -26,11 +26,10 @@ def _get_config_root() -> Path: return config_home -def _get_cache_root() -> Path: +def _get_data_root() -> Path: data_home = Path(os.environ.get("UC_DATA_HOME", "./data")) - cache_root = data_home / CACHE_ROOT - cache_root.mkdir(parents=True, exist_ok=True) - return cache_root + data_home.mkdir(parents=True, exist_ok=True) + return data_home @dataclass diff --git a/src/external_metadata.py b/src/external_metadata.py index 38f66fe..f37a93f 100644 --- a/src/external_metadata.py +++ b/src/external_metadata.py @@ -21,7 +21,7 @@ from PIL.Image import Resampling from pychromecast.controllers.media import MediaImage from sanitize_filename import sanitize -from config import _get_config_root, _get_cache_root +from config import _get_config_root, _get_data_root _LOG = logging.getLogger(__name__) @@ -31,13 +31,13 @@ # Paths def _get_metadata_dir() -> Path: - metadata_dir = _get_cache_root() + metadata_dir = _get_data_root() / CACHE_ROOT metadata_dir.mkdir(parents=True, exist_ok=True) return metadata_dir def _get_icon_dir() -> Path: - icon_dir = _get_cache_root() / ICON_SUBDIR + icon_dir = _get_data_root() / CACHE_ROOT / ICON_SUBDIR icon_dir.mkdir(parents=True, exist_ok=True) return icon_dir From f57eb1e2749889816bae7dbdeb17eb922dbb735f Mon Sep 17 00:00:00 2001 From: Thomas Mason Date: Wed, 30 Apr 2025 23:58:41 +0100 Subject: [PATCH 23/46] linting --- src/adb_tv.py | 83 ++++++++++++++++++++++++++-------------- src/external_metadata.py | 1 + src/setup_flow.py | 5 +-- 3 files changed, 56 insertions(+), 33 deletions(-) diff --git a/src/adb_tv.py b/src/adb_tv.py index 0eaa2f3..88e038c 100644 --- a/src/adb_tv.py +++ b/src/adb_tv.py @@ -1,8 +1,13 @@ +""" +This module provides utilities for interacting with Android TVs via ADB (Android Debug Bridge). +It includes functions for managing ADB keys, connecting to devices, retrieving installed apps, +and verifying device authorization. +""" + import asyncio import os -import re from pathlib import Path -from typing import Dict, List, Optional +from typing import Dict, Optional from adb_shell.adb_device_async import AdbDeviceTcpAsync from adb_shell.auth.keygen import keygen @@ -13,14 +18,30 @@ def get_adb_key_paths(device_id: str) -> tuple[Path, Path]: - """Return the path to the private and public adb keys for a given device.""" + """ + Return the paths to the private and public ADB keys for a given device. + + Args: + device_id (str): The unique identifier for the device. + + Returns: + tuple[Path, Path]: Paths to the private and public key files. + """ priv = ADB_CERTS_DIR / f"adb_{device_id}" pub = ADB_CERTS_DIR / f"adb_{device_id}.pub" return priv, pub def load_or_generate_adb_keys(device_id: str) -> PythonRSASigner: - """Ensure ADB RSA keys exist for the device and return the signer.""" + """ + Ensure ADB RSA keys exist for the device and return the signer. + + Args: + device_id (str): The unique identifier for the device. + + Returns: + PythonRSASigner: The signer object for ADB authentication. + """ priv_path, pub_path = get_adb_key_paths(device_id) if not priv_path.exists() or not pub_path.exists(): @@ -34,6 +55,17 @@ def load_or_generate_adb_keys(device_id: str) -> PythonRSASigner: async def adb_connect(device_id: str, host: str, port: int = 5555) -> Optional[AdbDeviceTcpAsync]: + """ + Connect to an Android device via ADB. + + Args: + device_id (str): The unique identifier for the device. + host (str): The IP address or hostname of the device. + port (int, optional): The port number for the ADB connection. Defaults to 5555. + + Returns: + Optional[AdbDeviceTcpAsync]: The connected ADB device object, or None if the connection fails. + """ signer = load_or_generate_adb_keys(device_id) device = AdbDeviceTcpAsync(host, port, default_transport_timeout_s=9.0) @@ -46,39 +78,32 @@ async def adb_connect(device_id: str, host: str, port: int = 5555) -> Optional[A async def get_installed_apps(device: AdbDeviceTcpAsync) -> Dict[str, Dict[str, str]]: - """Retrieve list of installed non-system apps in structured format.""" + """ + Retrieve a list of installed non-system apps in a structured format. + + Args: + device (AdbDeviceTcpAsync): The connected ADB device. + + Returns: + Dict[str, Dict[str, str]]: A dictionary of app package names and their metadata. + """ output = await device.shell("pm list packages -3 -e") packages = sorted(line.replace("package:", "").strip() for line in output.splitlines()) return {package: {"url": f"market://launch?id={package}"} for package in packages} async def is_authorised(device: AdbDeviceTcpAsync) -> bool: + """ + Check if the connected device is authorized for ADB communication. + + Args: + device (AdbDeviceTcpAsync): The connected ADB device. + + Returns: + bool: True if the device is authorized, False otherwise. + """ try: result = await device.shell("echo ADB_OK") return "ADB_OK" in result except Exception: return False - - -async def test_connection(device_id: str, host: str) -> None: - device = await adb_connect(device_id, host) - if not device: - return - - if await is_authorised(device): - print("ADB authorisation confirmed.") - print("Current app:", await get_current_app(device)) - print("Installed apps:", await get_installed_apps(device)) - else: - print("Device not authorised. Please check the TV for an ADB prompt.") - - await device.close() - - -if __name__ == "__main__": - import sys - - if len(sys.argv) != 3: - print("Usage: python adb_tv.py ") - else: - asyncio.run(test_connection(sys.argv[1], sys.argv[2])) diff --git a/src/external_metadata.py b/src/external_metadata.py index f37a93f..b0732de 100644 --- a/src/external_metadata.py +++ b/src/external_metadata.py @@ -29,6 +29,7 @@ ICON_SUBDIR = "icons" ICON_SIZE = (240, 240) + # Paths def _get_metadata_dir() -> Path: metadata_dir = _get_data_root() / CACHE_ROOT diff --git a/src/setup_flow.py b/src/setup_flow.py index 390c881..097c0a3 100644 --- a/src/setup_flow.py +++ b/src/setup_flow.py @@ -12,9 +12,6 @@ from pathlib import Path import ucapi -from adb_shell.adb_device_async import AdbDeviceTcpAsync -from adb_shell.auth.keygen import keygen -from adb_shell.auth.sign_pythonrsa import PythonRSASigner from ucapi import ( AbortDriverSetup, DriverSetupRequest, @@ -27,7 +24,6 @@ UserDataResponse, ) -import adb_tv import config import discover import tv @@ -714,6 +710,7 @@ async def handle_user_data_pin(msg: UserDataResponse) -> RequestUserInput | Setu settings=settings, ) + async def handle_app_selection(msg: UserDataResponse) -> SetupComplete | SetupError: global _pairing_android_tv global _use_chromecast From 447086e6b861a90469d02c7a308e466822fff779 Mon Sep 17 00:00:00 2001 From: Thomas Mason Date: Thu, 1 May 2025 08:42:57 +0100 Subject: [PATCH 24/46] adding send_text method --- src/tv.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/tv.py b/src/tv.py index 77b9a7e..1f4c66f 100644 --- a/src/tv.py +++ b/src/tv.py @@ -858,6 +858,12 @@ async def _launch_app(self, app: str) -> ucapi.StatusCodes: self._atv.send_launch_app_command(app) return ucapi.StatusCodes.OK + @async_handle_atvlib_errors + async def _send_text(self, text: str) -> ucapi.StatusCodes: + """Launch an app on Android TV.""" + self._atv.send_text(text) + return ucapi.StatusCodes.OK + async def _switch_input(self, source: str) -> ucapi.StatusCodes: """ TEST FUNCTION: Send a KEYCODE_TV_INPUT_* key. From 5044cd3ac83a0bc6e422d360264a06219a4d94b5 Mon Sep 17 00:00:00 2001 From: Thomas Mason Date: Thu, 1 May 2025 09:27:55 +0100 Subject: [PATCH 25/46] adding in simple commands for text entry via androidremote2 --- src/profiles.py | 13 +++++++++++++ src/tv.py | 10 +++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/profiles.py b/src/profiles.py index ac5eace..d7ea92c 100644 --- a/src/profiles.py +++ b/src/profiles.py @@ -263,6 +263,19 @@ def match(self, manufacturer: str, model: str, use_chromecast: bool) -> Profile: select_profile = copy.copy(select_profile) select_profile.features.extend(CHROMECAST_FEATURES) + import string + for char in string.ascii_uppercase + " ": + # Use 'SPACE' as the key name for the space character + key = "SPACE" if char == " " else char + command_name = f"TEXT_{key}" + + # Append to simple_commands list + select_profile.simple_commands.append(command_name) + + # Map the command: space gets ' ', others get lowercase letter + command_value = " " if char == " " else char.lower() + select_profile.command_map[command_name] = Command(command_value, 'TEXT') + return select_profile diff --git a/src/tv.py b/src/tv.py index 1f4c66f..ee6ebd2 100644 --- a/src/tv.py +++ b/src/tv.py @@ -842,7 +842,15 @@ async def _send_command(self, keycode: int | str, action: KeyPress = KeyPress.SH else: direction = "SHORT" - self._atv.send_key_command(keycode, direction) + if action == 'TEXT': + # Special handling for text input + if not isinstance(keycode, str): + _LOG.error("[%s] Cannot send command, invalid key_code: %s", self.log_id, keycode) + return ucapi.StatusCodes.BAD_REQUEST + self._atv.send_text(keycode) + return ucapi.StatusCodes.OK + else: + self._atv.send_key_command(keycode, direction) if action == KeyPress.DOUBLE_CLICK: self._atv.send_key_command(keycode, direction) From b76d49b1876b5958fc4e675b38b3fdddddaad413 Mon Sep 17 00:00:00 2001 From: Thomas Mason Date: Thu, 1 May 2025 09:31:49 +0100 Subject: [PATCH 26/46] clean up --- src/tv.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/tv.py b/src/tv.py index ee6ebd2..35eeb72 100644 --- a/src/tv.py +++ b/src/tv.py @@ -835,13 +835,6 @@ async def _send_command(self, keycode: int | str, action: KeyPress = KeyPress.SH :return: OK if scheduled to be sent, other error code in case of an error """ # noqa - if action in (KeyPress.LONG, KeyPress.BEGIN): - direction = "START_LONG" - elif action == KeyPress.END: - direction = "END_LONG" - else: - direction = "SHORT" - if action == 'TEXT': # Special handling for text input if not isinstance(keycode, str): @@ -849,8 +842,15 @@ async def _send_command(self, keycode: int | str, action: KeyPress = KeyPress.SH return ucapi.StatusCodes.BAD_REQUEST self._atv.send_text(keycode) return ucapi.StatusCodes.OK + + if action in (KeyPress.LONG, KeyPress.BEGIN): + direction = "START_LONG" + elif action == KeyPress.END: + direction = "END_LONG" else: - self._atv.send_key_command(keycode, direction) + direction = "SHORT" + + self._atv.send_key_command(keycode, direction) if action == KeyPress.DOUBLE_CLICK: self._atv.send_key_command(keycode, direction) From 1a01857bac9dfb777054b6f472fc34f8e7f0f99b Mon Sep 17 00:00:00 2001 From: Thomas Mason Date: Thu, 1 May 2025 09:44:41 +0100 Subject: [PATCH 27/46] add text backspace support --- src/profiles.py | 3 +++ src/tv.py | 5 ++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/profiles.py b/src/profiles.py index d7ea92c..f9a5737 100644 --- a/src/profiles.py +++ b/src/profiles.py @@ -276,6 +276,9 @@ def match(self, manufacturer: str, model: str, use_chromecast: bool) -> Profile: command_value = " " if char == " " else char.lower() select_profile.command_map[command_name] = Command(command_value, 'TEXT') + select_profile.simple_commands.append('TEXT_BACKSPACE') + select_profile.command_map['TEXT_BACKSPACE'] = Command('DEL', 'TEXT') + return select_profile diff --git a/src/tv.py b/src/tv.py index 35eeb72..06ede1c 100644 --- a/src/tv.py +++ b/src/tv.py @@ -840,7 +840,10 @@ async def _send_command(self, keycode: int | str, action: KeyPress = KeyPress.SH if not isinstance(keycode, str): _LOG.error("[%s] Cannot send command, invalid key_code: %s", self.log_id, keycode) return ucapi.StatusCodes.BAD_REQUEST - self._atv.send_text(keycode) + if keycode == 'DEL': + self._atv.send_key_command(keycode, 'SHORT') + else: + self._atv.send_text(keycode) return ucapi.StatusCodes.OK if action in (KeyPress.LONG, KeyPress.BEGIN): From ca2c5d4eb113d17c94ef2b8950acdcab48f450e7 Mon Sep 17 00:00:00 2001 From: Thomas Mason Date: Thu, 1 May 2025 11:19:12 +0100 Subject: [PATCH 28/46] updating androidtv pem location to go into /certs folder --- src/config.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/config.py b/src/config.py index d1ca887..8c8baea 100644 --- a/src/config.py +++ b/src/config.py @@ -155,19 +155,19 @@ def update(self, atv: AtvDevice) -> bool: def default_certfile(self) -> str: """Return the default certificate file for initializing a device.""" - return os.path.join(self._data_path, "androidtv_remote_cert.pem") + return os.path.join(self._data_path + "/certs", "androidtv_remote_cert.pem") def default_keyfile(self) -> str: """Return the default key file for initializing a device.""" - return os.path.join(self._data_path, "androidtv_remote_key.pem") + return os.path.join(self._data_path + "/certs", "androidtv_remote_key.pem") def certfile(self, atv_id: str) -> str: """Return the certificate file of the device.""" - return os.path.join(self._data_path, f"androidtv_{atv_id}_remote_cert.pem") + return os.path.join(self._data_path + "/certs", f"androidtv_{atv_id}_remote_cert.pem") def keyfile(self, atv_id: str) -> str: """Return the key file of the device.""" - return os.path.join(self._data_path, f"androidtv_{atv_id}_remote_key.pem") + return os.path.join(self._data_path + "/certs", f"androidtv_{atv_id}_remote_key.pem") def remove(self, atv_id: str) -> bool: """Remove the given device configuration.""" From 0f0f85173ba83dfbec774e87655fb06ac7f4d733 Mon Sep 17 00:00:00 2001 From: Thomas Mason Date: Thu, 1 May 2025 20:33:50 +0100 Subject: [PATCH 29/46] adding in enter --- src/profiles.py | 5 ++++- src/tv.py | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/profiles.py b/src/profiles.py index f9a5737..9cd51ce 100644 --- a/src/profiles.py +++ b/src/profiles.py @@ -277,7 +277,10 @@ def match(self, manufacturer: str, model: str, use_chromecast: bool) -> Profile: select_profile.command_map[command_name] = Command(command_value, 'TEXT') select_profile.simple_commands.append('TEXT_BACKSPACE') - select_profile.command_map['TEXT_BACKSPACE'] = Command('DEL', 'TEXT') + select_profile.command_map['TEXT_BACKSPACE'] = Command('DEL', KeyPress.SHORT) + + select_profile.simple_commands.append('TEXT_ENTER') + select_profile.command_map['TEXT_ENTER'] = Command('ENTER', KeyPress.SHORT) return select_profile diff --git a/src/tv.py b/src/tv.py index 06ede1c..fbd0ed4 100644 --- a/src/tv.py +++ b/src/tv.py @@ -840,7 +840,7 @@ async def _send_command(self, keycode: int | str, action: KeyPress = KeyPress.SH if not isinstance(keycode, str): _LOG.error("[%s] Cannot send command, invalid key_code: %s", self.log_id, keycode) return ucapi.StatusCodes.BAD_REQUEST - if keycode == 'DEL': + if keycode in ('DEL', 'ENTER'): self._atv.send_key_command(keycode, 'SHORT') else: self._atv.send_text(keycode) From 9d2525a77997b0f18d9b55a76a82890ffd4a395b Mon Sep 17 00:00:00 2001 From: Thomas Mason Date: Fri, 2 May 2025 13:47:08 +0100 Subject: [PATCH 30/46] clean up app list logic - remove duplicates and sort alpha and make form dynamic --- src/setup_flow.py | 87 ++++++++++++++++++++++++++++------------------- 1 file changed, 52 insertions(+), 35 deletions(-) diff --git a/src/setup_flow.py b/src/setup_flow.py index 097c0a3..594279f 100644 --- a/src/setup_flow.py +++ b/src/setup_flow.py @@ -636,45 +636,75 @@ async def handle_user_data_pin(msg: UserDataResponse) -> RequestUserInput | Setu from apps import Apps, IdMappings from external_metadata import get_app_metadata + # STEP 1: Resolve canonical friendly names used in offline mappings + offline_friendly_names = set(IdMappings.values()) + offline_package_ids = set(Apps.keys()) + if _use_adb: - adb_apps = await get_installed_apps(adb_device) # dict[str, dict[str, str]] - offline_apps = {**Apps, **adb_apps} # ADB apps override Apps if same name + adb_apps = await get_installed_apps(adb_device) _LOG.debug("Retrieved ADB apps: %s", adb_apps) await adb_device.close() + + # STEP 2: Remove ADB entries that duplicate an offline friendly name + filtered_adb_apps = {} + for package, details in adb_apps.items(): + name_from_mapping = IdMappings.get(package) + if name_from_mapping and name_from_mapping in offline_friendly_names: + continue # Duplicate friendly name already exists in offline Apps + if package in Apps: + continue # Exact package already exists in offline Apps + filtered_adb_apps[package] = details + + # STEP 3: Merge with Apps, giving priority to offline Apps + merged_apps = {**filtered_adb_apps, **Apps} else: - offline_apps = Apps + merged_apps = Apps - _LOG.debug("Retrieved offline apps: %s", offline_apps) + _LOG.debug("Merged apps (offline preferred): %s", merged_apps) - settings = [] + # STEP 4: Build app list for rendering + app_entries = [] - for package, details in sorted(offline_apps.items()): - # Start with mapped name or static name + for package, details in merged_apps.items(): mapped_name = IdMappings.get(package) static_name = details.get("name", package) name = mapped_name or static_name + editable = False - # Attempt to get external metadata if name is still just package id if _use_external_metadata and not mapped_name: try: metadata = await get_app_metadata(package) - name = metadata.get("name", name) + if metadata.get("name"): + name = metadata["name"] + editable = True # Only editable if name came from metadata except Exception as e: _LOG.warning("Metadata lookup failed for %s: %s", package, e) - if _use_adb and package in adb_apps: - # ADB-specific apps: checkbox + friendly name input - settings.append( - { - "id": f"{package}_enabled", - "label": { - "en": f"Enable {package}", - "de": f"Aktiviere {package}", - "fr": f"Activer {package}", - }, - "field": {"checkbox": {"value": False}}, - } - ) + is_unfriendly = name.startswith("com.") or name.count('.') >= 2 + app_entries.append((is_unfriendly, name.lower(), package, name, editable)) + + # STEP 5: Sort by friendly/unfriendly then alphabetically + app_entries.sort() + + settings = [] + + # STEP 6: Output settings based on filtered and sorted entries + for _, _, package, name, editable in app_entries: + is_adb_only = _use_adb and package in adb_apps and package not in Apps + + settings.append( + { + "id": f"{package}_enabled", + "label": { + "en": name if not is_adb_only else f"{package}", + "de": name if not is_adb_only else f"{package}", + "fr": name if not is_adb_only else f"{package}", + }, + "field": {"checkbox": {"value": False}}, + } + ) + + if is_adb_only and editable: settings.append( { "id": f"{package}_name", @@ -686,19 +716,6 @@ async def handle_user_data_pin(msg: UserDataResponse) -> RequestUserInput | Setu "field": {"text": {"value": name}}, } ) - else: - # Predefined apps: checkbox only - settings.append( - { - "id": f"{package}_enabled", - "label": { - "en": name, - "de": name, - "fr": name, - }, - "field": {"checkbox": {"value": False}}, - } - ) _setup_step = SetupSteps.APP_SELECTION return RequestUserInput( From 0405df231cdfd6a10c3804f1b9bbeece4c345daf Mon Sep 17 00:00:00 2001 From: Thomas Mason Date: Fri, 2 May 2025 13:51:13 +0100 Subject: [PATCH 31/46] clean up app list logic --- src/setup_flow.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/setup_flow.py b/src/setup_flow.py index 594279f..be6189f 100644 --- a/src/setup_flow.py +++ b/src/setup_flow.py @@ -648,13 +648,16 @@ async def handle_user_data_pin(msg: UserDataResponse) -> RequestUserInput | Setu # STEP 2: Remove ADB entries that duplicate an offline friendly name filtered_adb_apps = {} for package, details in adb_apps.items(): - name_from_mapping = IdMappings.get(package) - if name_from_mapping and name_from_mapping in offline_friendly_names: - continue # Duplicate friendly name already exists in offline Apps if package in Apps: - continue # Exact package already exists in offline Apps + continue # Already covered offline (exact match) + if package in IdMappings: + continue # Already has a mapped friendly name offline + friendly = details.get("name", "") + if friendly and friendly in IdMappings.values(): + continue # Another offline app already uses this friendly name filtered_adb_apps[package] = details + # STEP 3: Merge with Apps, giving priority to offline Apps merged_apps = {**filtered_adb_apps, **Apps} else: From 00de3230447574470de7328a0c4bf8bc89609f9d Mon Sep 17 00:00:00 2001 From: Thomas Mason Date: Fri, 2 May 2025 13:59:46 +0100 Subject: [PATCH 32/46] clean up app list logic --- src/setup_flow.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/setup_flow.py b/src/setup_flow.py index be6189f..9a0fbe5 100644 --- a/src/setup_flow.py +++ b/src/setup_flow.py @@ -315,9 +315,7 @@ async def handle_configuration_mode( _setup_step = SetupSteps.RECONFIGURE _reconfigured_device = selected_device use_chromecast = selected_device.use_chromecast if selected_device.use_chromecast else False - use_external_metadata = ( - selected_device.use_external_metadata if selected_device.use_external_metadata else False - ) + use_external_metadata = selected_device.use_external_metadata if selected_device.use_external_metadata else False use_adb = selected_device.use_adb if selected_device.use_adb else False return RequestUserInput( From e05a1dc39bb1617112e4aa1fc0a6bd997d440b40 Mon Sep 17 00:00:00 2001 From: Thomas Mason Date: Tue, 6 May 2025 00:15:15 +0100 Subject: [PATCH 33/46] adding further external metadata to pull youtube artwork --- src/external_metadata.py | 62 ++++++++++++++++++++++++++++++++++++++-- src/tv.py | 25 ++++++++++------ 2 files changed, 77 insertions(+), 10 deletions(-) diff --git a/src/external_metadata.py b/src/external_metadata.py index b0732de..f4557a1 100644 --- a/src/external_metadata.py +++ b/src/external_metadata.py @@ -13,7 +13,9 @@ from io import BytesIO from pathlib import Path from typing import Dict -from urllib.parse import urlparse +from urllib.parse import urlparse, quote +from bs4 import BeautifulSoup +import re import google_play_scraper import httpx @@ -56,7 +58,6 @@ def _get_icon_path(icon_name: str) -> Path: return _get_config_root() / "icons" / sanitize(icon_name[9:]) return _get_icon_dir() / sanitize(icon_name) - # Cache Management def _load_cache() -> Dict[str, Dict[str, str]]: path = _get_metadata_file_path() @@ -217,3 +218,60 @@ async def get_app_metadata(package_id: str) -> Dict[str, str]: _LOG.debug("Falling back to default metadata for %s", package_id) return {"name": package_id, "icon": ""} + + +async def youtube_search(query: str, limit: int = 1): + url = f"https://www.youtube.com/results?search_query={quote(query)}" + headers = { + "User-Agent": "Mozilla/5.0" + } + + with httpx.Client(headers=headers, timeout=10) as client: + response = client.get(url) + html = response.text + + # Extract the ytInitialData JSON + match = re.search(r"var ytInitialData = ({.*?});", html) + if not match: + raise RuntimeError("Could not find ytInitialData in the page") + + data = json.loads(match.group(1)) + + try: + items = ( + data["contents"]["twoColumnSearchResultsRenderer"] + ["primaryContents"]["sectionListRenderer"] + ["contents"][0]["itemSectionRenderer"]["contents"] + ) + except (KeyError, IndexError): + raise RuntimeError("Could not parse YouTube data structure") + + results = [] + for item in items: + if "videoRenderer" in item: + video = item["videoRenderer"] + video_id = video.get("videoId") + results.append({"artwork": f"https://img.youtube.com/vi/{video_id}/0.jpg"}) + + if len(results) >= limit: + break + + return results + +async def get_best_artwork(title: str, artist: str = None, current_package: str = None) -> Dict[str, str] | bool: + _LOG.debug("Resolving best artwork for title='%s', artist='%s', current_package='%s'", title, artist, current_package) + + if current_package in ["com.google.android.youtube.tv", "com.liskovsoft.videomanager", "com.teamsmart.videomanager.tv"]: + + _LOG.debug("YouTube/SmartTube detected. Searching for artwork.") + + youtube = await youtube_search(title) + + if youtube: + _LOG.debug("Artwork result:\n%s", json.dumps(youtube, indent=2)) + return youtube[0] + else: + _LOG.debug("No artwork found from YouTube search.") + + _LOG.debug("No artwork source applicable. Returning False.") + return False \ No newline at end of file diff --git a/src/tv.py b/src/tv.py index fbd0ed4..cb4a40f 100644 --- a/src/tv.py +++ b/src/tv.py @@ -51,7 +51,7 @@ import discover import inputs from config import AtvDevice, _get_config_root -from external_metadata import encode_icon_to_data_uri, get_app_metadata +from external_metadata import encode_icon_to_data_uri, get_app_metadata, get_best_artwork from profiles import KeyPress, Profile from util import filter_data_img_properties @@ -938,7 +938,7 @@ async def _handle_new_media_status(self, status: MediaStatus): # Update position every 30 seconds if changed_duration or ( - current_time != self._media_position and self._last_update_position_time + 30 < time.time() + 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 @@ -946,8 +946,8 @@ async def _handle_new_media_status(self, status: MediaStatus): 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 + 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(status.metadata_type, MediaType.VIDEO) update[MediaAttr.MEDIA_TYPE] = self._media_type @@ -958,10 +958,19 @@ async def _handle_new_media_status(self, status: MediaStatus): 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] = await encode_icon_to_data_uri(self._app_image_url) + + if self._atv.current_app and (status.title and status.artist): + artwork = await get_best_artwork(status.title, status.artist, self._atv.current_app) + _LOG.debug("Artwork result:\n%s", json.dumps(artwork, indent=2)) + + if artwork: + update[MediaAttr.MEDIA_IMAGE_URL] = await encode_icon_to_data_uri(artwork["artwork"]) + self._use_app_url = False + else: + if self._device_config.use_external_metadata: + self._use_app_url = True + if self._app_image_url: + update[MediaAttr.MEDIA_IMAGE_URL] = await encode_icon_to_data_uri(self._app_image_url) if update: if _LOG.isEnabledFor(logging.DEBUG): From 4af1e62035e1ab634e976bb0d6835acf8df351f7 Mon Sep 17 00:00:00 2001 From: Thomas Mason Date: Tue, 6 May 2025 00:15:47 +0100 Subject: [PATCH 34/46] adding further external metadata to pull youtube artwork --- src/tv.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tv.py b/src/tv.py index cb4a40f..35dd37b 100644 --- a/src/tv.py +++ b/src/tv.py @@ -959,7 +959,7 @@ async def _handle_new_media_status(self, status: MediaStatus): else: self._media_image_url = None - if self._atv.current_app and (status.title and status.artist): + if self._atv.current_app and (status.title and status.artist) and self._device_config.use_external_metadata: artwork = await get_best_artwork(status.title, status.artist, self._atv.current_app) _LOG.debug("Artwork result:\n%s", json.dumps(artwork, indent=2)) From e655722610b7506106778163b32d545cb42c2f33 Mon Sep 17 00:00:00 2001 From: Thomas Mason Date: Tue, 6 May 2025 12:06:52 +0100 Subject: [PATCH 35/46] testing --- requirements.txt | 3 ++- src/external_metadata.py | 51 +++++++++++++++++++++++++++++++++------- src/tv.py | 26 ++++++++++---------- 3 files changed, 58 insertions(+), 22 deletions(-) diff --git a/requirements.txt b/requirements.txt index 9d94bb7..842ea31 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,4 +7,5 @@ requests>=2.32 pychromecast~=14.0.7 httpx~=0.28.1 sanitize-filename~=1.2.0 -adb-shell==0.4.4 \ No newline at end of file +adb-shell==0.4.4 +JustWatch==0.5.1 \ No newline at end of file diff --git a/src/external_metadata.py b/src/external_metadata.py index f4557a1..9b87f78 100644 --- a/src/external_metadata.py +++ b/src/external_metadata.py @@ -24,6 +24,7 @@ from pychromecast.controllers.media import MediaImage from sanitize_filename import sanitize from config import _get_config_root, _get_data_root +from simplejustwatchapi import justwatch _LOG = logging.getLogger(__name__) @@ -246,32 +247,64 @@ async def youtube_search(query: str, limit: int = 1): except (KeyError, IndexError): raise RuntimeError("Could not parse YouTube data structure") - results = [] for item in items: if "videoRenderer" in item: video = item["videoRenderer"] video_id = video.get("videoId") - results.append({"artwork": f"https://img.youtube.com/vi/{video_id}/0.jpg"}) - if len(results) >= limit: - break + return f"https://img.youtube.com/vi/{video_id}/0.jpg" - return results + return False + +async def search_poster_justwatch(query: str, country: str = "GB", limit: int = 1) -> list[dict]: + """Search for poster images using JustWatch API.""" + + response = justwatch.search(query, country, 'en', count=1, best_only=True) + + if not response[0].poster: + return None + else: + poster_url = response[0].poster + + if poster_url: + return poster_url + + return False async def get_best_artwork(title: str, artist: str = None, current_package: str = None) -> Dict[str, str] | bool: _LOG.debug("Resolving best artwork for title='%s', artist='%s', current_package='%s'", title, artist, current_package) + search_query = f"{title} - {artist}" if artist else title + if current_package in ["com.google.android.youtube.tv", "com.liskovsoft.videomanager", "com.teamsmart.videomanager.tv"]: - _LOG.debug("YouTube/SmartTube detected. Searching for artwork.") + _LOG.debug("YouTube detected. Searching for artwork.") - youtube = await youtube_search(title) + youtube = await youtube_search(search_query) if youtube: _LOG.debug("Artwork result:\n%s", json.dumps(youtube, indent=2)) - return youtube[0] + return youtube else: _LOG.debug("No artwork found from YouTube search.") + else: + + _LOG.debug("Non-YouTube package detected. Searching for artwork.") + justwatch = await search_poster_justwatch(search_query) + + if justwatch: + _LOG.debug("Artwork result:\n%s", json.dumps(justwatch, indent=2)) + return justwatch + else: + _LOG.debug("No artwork found from JustWatch search.") + _LOG.debug("No artwork source applicable. Returning False.") - return False \ No newline at end of file + return False + + +# async def test(): +# posters = await get_best_artwork("Episode 1", "Breaking Bad", "com.plexapp.android") +# print(posters) +# +# asyncio.run(test()) \ No newline at end of file diff --git a/src/tv.py b/src/tv.py index 35dd37b..27e9673 100644 --- a/src/tv.py +++ b/src/tv.py @@ -959,18 +959,20 @@ async def _handle_new_media_status(self, status: MediaStatus): else: self._media_image_url = None - if self._atv.current_app and (status.title and status.artist) and self._device_config.use_external_metadata: - artwork = await get_best_artwork(status.title, status.artist, self._atv.current_app) - _LOG.debug("Artwork result:\n%s", json.dumps(artwork, indent=2)) - - if artwork: - update[MediaAttr.MEDIA_IMAGE_URL] = await encode_icon_to_data_uri(artwork["artwork"]) - self._use_app_url = False - else: - if self._device_config.use_external_metadata: - self._use_app_url = True - if self._app_image_url: - update[MediaAttr.MEDIA_IMAGE_URL] = await encode_icon_to_data_uri(self._app_image_url) + if self._device_config.use_external_metadata: + if status.title and status.artist: + # Use external metadata to get artwork + _LOG.debug("[%s] Requesting artwork for %s by %s", self.log_id, status.title, status.artist) + artwork_url = await get_best_artwork(status.title, status.artist, self._atv.current_app) + _LOG.debug("Artwork result:\n%s", json.dumps(artwork_url, indent=2)) + + if artwork_url: + self._media_image_url = artwork_url + update[MediaAttr.MEDIA_IMAGE_URL] = await encode_icon_to_data_uri(artwork_url) + self._use_app_url = False + + if self._app_image_url: + update[MediaAttr.MEDIA_IMAGE_URL] = await encode_icon_to_data_uri(self._app_image_url) if update: if _LOG.isEnabledFor(logging.DEBUG): From 9d893874c9cf1514647e549d975349ec80f9b191 Mon Sep 17 00:00:00 2001 From: Thomas Mason Date: Tue, 6 May 2025 13:18:10 +0100 Subject: [PATCH 36/46] unified media handling --- requirements.txt | 2 +- src/external_metadata.py | 3 +- src/tv.py | 344 +++++++++++++++++++-------------------- 3 files changed, 167 insertions(+), 182 deletions(-) diff --git a/requirements.txt b/requirements.txt index 842ea31..882cf08 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,4 +8,4 @@ pychromecast~=14.0.7 httpx~=0.28.1 sanitize-filename~=1.2.0 adb-shell==0.4.4 -JustWatch==0.5.1 \ No newline at end of file +simple-justwatch-python-api>=0.16 \ No newline at end of file diff --git a/src/external_metadata.py b/src/external_metadata.py index 9b87f78..3e64de4 100644 --- a/src/external_metadata.py +++ b/src/external_metadata.py @@ -14,7 +14,6 @@ from pathlib import Path from typing import Dict from urllib.parse import urlparse, quote -from bs4 import BeautifulSoup import re import google_play_scraper @@ -218,7 +217,7 @@ async def get_app_metadata(package_id: str) -> Dict[str, str]: return {"name": metadata["name"], "icon": icon_data_uri} _LOG.debug("Falling back to default metadata for %s", package_id) - return {"name": package_id, "icon": ""} + return {"name": "", "icon": ""} async def youtube_search(query: str, limit: int = 1): diff --git a/src/tv.py b/src/tv.py index 27e9673..9304747 100644 --- a/src/tv.py +++ b/src/tv.py @@ -233,6 +233,8 @@ def __init__( self._use_app_url = not device_config.use_chromecast self._player_state = media_player.States.ON self._muted = False + self._is_chromecast_playing = False + self._chromecast_metadata_active = False def __del__(self): """Destructs instance, disconnect AndroidTVRemote.""" @@ -595,111 +597,50 @@ def disconnect(self) -> None: self.events.emit(Events.DISCONNECTED, self._identifier) # Callbacks - async def _apply_current_app_metadata(self, current_app: str) -> dict: + + def _is_on_updated(self, is_on: bool) -> None: + if not self._loop or not self._loop.is_running(): + _LOG.warning("[%s] No running event loop for power update", self.log_id) + return + asyncio.run_coroutine_threadsafe(self._update_media_status(), self._loop) + + async def _handle_is_on_updated(self, is_on: bool): global HOMESCREEN_IMAGE update = {} - # one-time initialization + if HOMESCREEN_IMAGE is None: - HOMESCREEN_IMAGE = "" HOMESCREEN_IMAGE = await encode_icon_to_data_uri("config://androidtv.png") - # Special handling for homescreen & Android TV system apps: show pre-defined icon + current_app = self._atv.current_app or "" homescreen_app = apps.is_homescreen_app(current_app) - if homescreen_app or apps.is_standby_app(current_app): - update[MediaAttr.SOURCE] = apps.IdMappings[current_app] - update[MediaAttr.MEDIA_TITLE] = "" - update[MediaAttr.MEDIA_IMAGE_URL] = HOMESCREEN_IMAGE - update[MediaAttr.STATE] = ( - media_player.States.ON.value if homescreen_app else media_player.States.STANDBY.value - ) - return update - # Track state of data sources - offline_name = None - offline_match = None - external_name = None - external_icon = None - - # Try offline ID mapping first - if current_app in apps.IdMappings: - offline_name = apps.IdMappings[current_app] - self._media_app = offline_name - - # Try fuzzy offline name matching if ID mapping failed - if not offline_name: - for query, name in apps.NameMatching.items(): - if query in current_app: - offline_match = name - self._media_app = name - break - - # Try external metadata - metadata = ( - await get_app_metadata(current_app) if current_app and self._device_config.use_external_metadata else None + update[MediaAttr.STATE] = ( + media_player.States.ON.value if is_on else media_player.States.OFF.value ) - if metadata: - if _LOG.isEnabledFor(logging.DEBUG): - _LOG.debug("App metadata: %s", filter_data_img_properties(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 - - # Determine final name/title to use - name_to_use = offline_name or offline_match or external_name or current_app - # TODO why set name to both source & media title fields? - update[MediaAttr.SOURCE] = 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 or self._use_app_url: - if external_icon: - icon_to_use = external_icon - else: - icon_to_use = "" - elif self._media_image_url: - icon_to_use = await encode_icon_to_data_uri(self._media_image_url) - - 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] = HOMESCREEN_IMAGE - 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 = await self._apply_current_app_metadata(current_app) - update[MediaAttr.STATE] = media_player.States.ON.value + if not is_on: + # Reset Chromecast state on device off + self._chromecast_metadata_active = False + self._media_title = "" + self._media_image_url = HOMESCREEN_IMAGE + update[MediaAttr.MEDIA_TITLE] = "" + update[MediaAttr.MEDIA_IMAGE_URL] = HOMESCREEN_IMAGE else: - update = await self._apply_current_app_metadata(current_app) - update[MediaAttr.STATE] = media_player.States.OFF.value + # Device turned on, set to homescreen/app metadata + app_name = apps.IdMappings.get(current_app) + self._media_app = app_name + update[MediaAttr.SOURCE] = app_name + update[MediaAttr.MEDIA_TITLE] = app_name if not homescreen_app else "" + update[MediaAttr.MEDIA_IMAGE_URL] = HOMESCREEN_IMAGE 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 = await self._apply_current_app_metadata(current_app) - self.events.emit(Events.UPDATE, self._identifier, update) + if not self._loop or not self._loop.is_running(): + _LOG.warning("[%s] No running event loop for app update", self.log_id) + return + asyncio.run_coroutine_threadsafe(self._update_media_status(), self._loop) def _volume_info_updated(self, volume_info: dict[str, str | bool]) -> None: """Notify that the Android TV volume information is updated.""" @@ -890,97 +831,10 @@ def new_connection_status(self, status: ConnectionStatus) -> None: _LOG.info("[%s] Received Chromecast connection status : %s", self.log_id, status) def new_media_status(self, status: MediaStatus) -> None: - """Receive new media status event from Google cast.""" 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) + _LOG.warning("[%s] No running event loop for Chromecast 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 - ): - # PLAYING, PAUSED, IDLE - 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 or "" - update[MediaAttr.MEDIA_ALBUM] = self._media_album - - if status.artist != self._media_artist: - 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 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 - changed_duration = False - - if duration != self._media_duration: - self._media_duration = duration - update[MediaAttr.MEDIA_DURATION] = self._media_duration - changed_duration = True - - # Update position every 30 seconds - 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(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] = 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: - if status.title and status.artist: - # Use external metadata to get artwork - _LOG.debug("[%s] Requesting artwork for %s by %s", self.log_id, status.title, status.artist) - artwork_url = await get_best_artwork(status.title, status.artist, self._atv.current_app) - _LOG.debug("Artwork result:\n%s", json.dumps(artwork_url, indent=2)) - - if artwork_url: - self._media_image_url = artwork_url - update[MediaAttr.MEDIA_IMAGE_URL] = await encode_icon_to_data_uri(artwork_url) - self._use_app_url = False - - if self._app_image_url: - update[MediaAttr.MEDIA_IMAGE_URL] = await encode_icon_to_data_uri(self._app_image_url) - - if update: - if _LOG.isEnabledFor(logging.DEBUG): - _LOG.debug( - "[%s] Update remote with Chromecast info : %s", self.log_id, filter_data_img_properties(update) - ) - - self.events.emit(Events.UPDATE, self._identifier, update) + asyncio.run_coroutine_threadsafe(self._update_media_status(), self._loop) def load_media_failed(self, queue_item_id: int, error_code: int) -> None: """Receive new media failed event from Google cast.""" @@ -1064,3 +918,135 @@ async def volume_set(self, volume: float | None) -> ucapi.StatusCodes: except PyChromecastError as ex: _LOG.error("[%s] Chromecast error sending command : %s", self.log_id, ex) return ucapi.StatusCodes.BAD_REQUEST + + async def _update_media_status(self): + global HOMESCREEN_IMAGE + + update = {} + + # Initialize homescreen icon once + if HOMESCREEN_IMAGE is None: + HOMESCREEN_IMAGE = await encode_icon_to_data_uri("config://androidtv.png") + + current_app = self._atv.current_app or "" + is_on = self._atv.is_on + homescreen_app = apps.is_homescreen_app(current_app) + standby_app = apps.is_standby_app(current_app) + + # Device is OFF + if not is_on: + self._chromecast_metadata_active = False + update.update({ + MediaAttr.STATE: media_player.States.OFF.value, + MediaAttr.MEDIA_TITLE: "", + MediaAttr.MEDIA_IMAGE_URL: HOMESCREEN_IMAGE, + }) + self.events.emit(Events.UPDATE, self._identifier, update) + return + + # Device on homescreen or standby app + if homescreen_app or standby_app: + self._chromecast_metadata_active = False + update.update({ + MediaAttr.STATE: media_player.States.ON.value if homescreen_app else media_player.States.STANDBY.value, + MediaAttr.SOURCE: "", + MediaAttr.MEDIA_TITLE: "", + MediaAttr.MEDIA_IMAGE_URL: HOMESCREEN_IMAGE, + }) + self.events.emit(Events.UPDATE, self._identifier, update) + return + + # Chromecast status (only if enabled) + chromecast_playing_states = ( + MEDIA_PLAYER_STATE_PLAYING, + MEDIA_PLAYER_STATE_PAUSED, + MEDIA_PLAYER_STATE_BUFFERING, + ) + + chromecast_active = False + chromecast_status = None + + if self._device_config.use_chromecast and self._chromecast: + chromecast_status = self._chromecast.media_controller.status + chromecast_active = ( + self._chromecast.socket_client.is_connected and + chromecast_status and + chromecast_status.player_state in chromecast_playing_states and + (chromecast_status.title or chromecast_status.images) + ) + + self._chromecast_metadata_active = chromecast_active + + # App metadata from offline mappings + offline_name = apps.IdMappings.get(current_app) + offline_match = next( + (name for query, name in apps.NameMatching.items() if query in current_app), None + ) + + external_name = None + external_icon = None + + # External metadata (if enabled) + if self._device_config.use_external_metadata: + metadata = await get_app_metadata(current_app) + if metadata: + external_name = metadata.get("name") + external_icon = metadata.get("icon") + if _LOG.isEnabledFor(logging.DEBUG): + _LOG.debug("External app metadata: %s", filter_data_img_properties(metadata)) + + app_name = external_name or offline_name or offline_match or current_app + self._media_app = app_name + self._app_image_url = external_icon or "" + + update[MediaAttr.SOURCE] = app_name + + # --- Chromecast Active State Handling --- + if chromecast_active: + # Media Title from Chromecast (fallback to app_name) + self._media_title = chromecast_status.title or app_name + update[MediaAttr.MEDIA_TITLE] = self._media_title + + # Media Image from Chromecast + if chromecast_status.images and chromecast_status.images[0].url: + self._media_image_url = chromecast_status.images[0].url + + # External Artwork fallback (if enabled) + elif self._device_config.use_external_metadata and chromecast_status.title: + self._media_image_url = await get_best_artwork( + chromecast_status.title, chromecast_status.artist, current_app + ) or external_icon or HOMESCREEN_IMAGE + else: + self._media_image_url = external_icon or HOMESCREEN_IMAGE + + update[MediaAttr.MEDIA_IMAGE_URL] = await encode_icon_to_data_uri(self._media_image_url) + + update[MediaAttr.STATE] = GOOGLE_CAST_MEDIA_STATES_MAP.get( + chromecast_status.player_state, media_player.States.PLAYING + ) + + update.update({ + MediaAttr.MEDIA_ARTIST: chromecast_status.artist or "", + MediaAttr.MEDIA_ALBUM: chromecast_status.album_name or "", + MediaAttr.MEDIA_POSITION: int(chromecast_status.current_time or 0), + MediaAttr.MEDIA_DURATION: int(chromecast_status.duration or 0), + MediaAttr.SOURCE: app_name, + }) + + # --- Chromecast Inactive or Disabled: App-level metadata only --- + else: + self._media_title = app_name + self._media_image_url = self._app_image_url or HOMESCREEN_IMAGE + + update.update({ + MediaAttr.MEDIA_TITLE: app_name, + MediaAttr.MEDIA_IMAGE_URL: await encode_icon_to_data_uri(self._media_image_url), + MediaAttr.STATE: media_player.States.PLAYING.value, + MediaAttr.SOURCE: app_name, + MediaAttr.MEDIA_ARTIST: "", + MediaAttr.MEDIA_ALBUM: "", + MediaAttr.MEDIA_POSITION: 0, + MediaAttr.MEDIA_DURATION: 0, + }) + + self.events.emit(Events.UPDATE, self._identifier, update) From c7c2887fb7ddfabec20c2c8fb796d77b0c7082c4 Mon Sep 17 00:00:00 2001 From: Thomas Mason Date: Tue, 6 May 2025 13:27:50 +0100 Subject: [PATCH 37/46] tweaks --- src/tv.py | 36 ++---------------------------------- 1 file changed, 2 insertions(+), 34 deletions(-) diff --git a/src/tv.py b/src/tv.py index 9304747..2edf375 100644 --- a/src/tv.py +++ b/src/tv.py @@ -604,38 +604,6 @@ def _is_on_updated(self, is_on: bool) -> None: return asyncio.run_coroutine_threadsafe(self._update_media_status(), self._loop) - async def _handle_is_on_updated(self, is_on: bool): - global HOMESCREEN_IMAGE - - update = {} - - if HOMESCREEN_IMAGE is None: - HOMESCREEN_IMAGE = await encode_icon_to_data_uri("config://androidtv.png") - - current_app = self._atv.current_app or "" - homescreen_app = apps.is_homescreen_app(current_app) - - update[MediaAttr.STATE] = ( - media_player.States.ON.value if is_on else media_player.States.OFF.value - ) - - if not is_on: - # Reset Chromecast state on device off - self._chromecast_metadata_active = False - self._media_title = "" - self._media_image_url = HOMESCREEN_IMAGE - update[MediaAttr.MEDIA_TITLE] = "" - update[MediaAttr.MEDIA_IMAGE_URL] = HOMESCREEN_IMAGE - else: - # Device turned on, set to homescreen/app metadata - app_name = apps.IdMappings.get(current_app) - self._media_app = app_name - update[MediaAttr.SOURCE] = app_name - update[MediaAttr.MEDIA_TITLE] = app_name if not homescreen_app else "" - update[MediaAttr.MEDIA_IMAGE_URL] = HOMESCREEN_IMAGE - - self.events.emit(Events.UPDATE, self._identifier, update) - def _current_app_updated(self, current_app: str) -> None: if not self._loop or not self._loop.is_running(): _LOG.warning("[%s] No running event loop for app update", self.log_id) @@ -939,7 +907,7 @@ async def _update_media_status(self): update.update({ MediaAttr.STATE: media_player.States.OFF.value, MediaAttr.MEDIA_TITLE: "", - MediaAttr.MEDIA_IMAGE_URL: HOMESCREEN_IMAGE, + MediaAttr.MEDIA_IMAGE_URL: "", }) self.events.emit(Events.UPDATE, self._identifier, update) return @@ -951,7 +919,7 @@ async def _update_media_status(self): MediaAttr.STATE: media_player.States.ON.value if homescreen_app else media_player.States.STANDBY.value, MediaAttr.SOURCE: "", MediaAttr.MEDIA_TITLE: "", - MediaAttr.MEDIA_IMAGE_URL: HOMESCREEN_IMAGE, + MediaAttr.MEDIA_IMAGE_URL: "", }) self.events.emit(Events.UPDATE, self._identifier, update) return From ca4cf4bc749ce7a8b50e0c67882789125fba060d Mon Sep 17 00:00:00 2001 From: Thomas Mason Date: Tue, 6 May 2025 14:17:44 +0100 Subject: [PATCH 38/46] tweaks --- src/tv.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tv.py b/src/tv.py index 2edf375..0318cf2 100644 --- a/src/tv.py +++ b/src/tv.py @@ -907,6 +907,7 @@ async def _update_media_status(self): update.update({ MediaAttr.STATE: media_player.States.OFF.value, MediaAttr.MEDIA_TITLE: "", + MediaAttr.SOURCE: "", MediaAttr.MEDIA_IMAGE_URL: "", }) self.events.emit(Events.UPDATE, self._identifier, update) @@ -974,7 +975,6 @@ async def _update_media_status(self): # Media Title from Chromecast (fallback to app_name) self._media_title = chromecast_status.title or app_name update[MediaAttr.MEDIA_TITLE] = self._media_title - # Media Image from Chromecast if chromecast_status.images and chromecast_status.images[0].url: self._media_image_url = chromecast_status.images[0].url From 7e0d892a7c2e0a9204b5de569fbcc27be64b9745 Mon Sep 17 00:00:00 2001 From: Thomas Mason Date: Tue, 6 May 2025 16:04:46 +0100 Subject: [PATCH 39/46] tweaks --- src/external_metadata.py | 6 +++--- src/tv.py | 7 ++++--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/external_metadata.py b/src/external_metadata.py index 3e64de4..e4b9a2d 100644 --- a/src/external_metadata.py +++ b/src/external_metadata.py @@ -253,7 +253,7 @@ async def youtube_search(query: str, limit: int = 1): return f"https://img.youtube.com/vi/{video_id}/0.jpg" - return False + return None async def search_poster_justwatch(query: str, country: str = "GB", limit: int = 1) -> list[dict]: """Search for poster images using JustWatch API.""" @@ -268,7 +268,7 @@ async def search_poster_justwatch(query: str, country: str = "GB", limit: int = if poster_url: return poster_url - return False + return None async def get_best_artwork(title: str, artist: str = None, current_package: str = None) -> Dict[str, str] | bool: _LOG.debug("Resolving best artwork for title='%s', artist='%s', current_package='%s'", title, artist, current_package) @@ -299,7 +299,7 @@ async def get_best_artwork(title: str, artist: str = None, current_package: str _LOG.debug("No artwork found from JustWatch search.") _LOG.debug("No artwork source applicable. Returning False.") - return False + return None # async def test(): diff --git a/src/tv.py b/src/tv.py index 0318cf2..cf7e67a 100644 --- a/src/tv.py +++ b/src/tv.py @@ -973,14 +973,15 @@ async def _update_media_status(self): # --- Chromecast Active State Handling --- if chromecast_active: # Media Title from Chromecast (fallback to app_name) - self._media_title = chromecast_status.title or app_name - update[MediaAttr.MEDIA_TITLE] = self._media_title + self._media_title = chromecast_status.title if chromecast_status.title is not None else chromecast_status.artist or "" + update[MediaAttr.MEDIA_TITLE] = self._media_title or chromecast_status.artist + # Media Image from Chromecast if chromecast_status.images and chromecast_status.images[0].url: self._media_image_url = chromecast_status.images[0].url # External Artwork fallback (if enabled) - elif self._device_config.use_external_metadata and chromecast_status.title: + elif self._device_config.use_external_metadata and chromecast_status.player_state in ["PLAYING", "PAUSED", "BUFFERING"]: self._media_image_url = await get_best_artwork( chromecast_status.title, chromecast_status.artist, current_app ) or external_icon or HOMESCREEN_IMAGE From bbcdb4ded2272dd0fe5bb00104b191af976ba212 Mon Sep 17 00:00:00 2001 From: Thomas Mason Date: Wed, 14 May 2025 13:25:19 +0100 Subject: [PATCH 40/46] update --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index 882cf08..f5dc188 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,5 +7,6 @@ requests>=2.32 pychromecast~=14.0.7 httpx~=0.28.1 sanitize-filename~=1.2.0 +async_timeout~=5.0.1 adb-shell==0.4.4 simple-justwatch-python-api>=0.16 \ No newline at end of file From dd7bf9c83be05b24dad3f383ea82fae964e49ee9 Mon Sep 17 00:00:00 2001 From: Thomas Mason Date: Thu, 15 May 2025 16:27:23 +0100 Subject: [PATCH 41/46] fixing setup duplicate options --- src/setup_flow.py | 305 ++++++++++++++++++++++++++++++++-------------- 1 file changed, 213 insertions(+), 92 deletions(-) diff --git a/src/setup_flow.py b/src/setup_flow.py index 2b577ec..8f0a911 100644 --- a/src/setup_flow.py +++ b/src/setup_flow.py @@ -320,7 +320,9 @@ async def handle_configuration_mode( _setup_step = SetupSteps.RECONFIGURE _reconfigured_device = selected_device use_chromecast = selected_device.use_chromecast if selected_device.use_chromecast else False - use_adb = selected_device.use_adb if selected_device.use_adb else False + use_adb = ( + selected_device.use_adb if selected_device.use_adb else False + ) use_chromecast_volume = ( selected_device.use_chromecast_volume if selected_device.use_chromecast_volume else False ) @@ -336,37 +338,11 @@ async def handle_configuration_mode( "fr": "Configurez votre Android TV", }, [ - { - "id": "chromecast", - "label": { - "en": "Preview feature: Enable Chromecast features", - "de": "Vorschaufunktion: Aktiviere Chromecast-Features", - "fr": "Fonctionnalité en aperçu: Activer les fonctionnalités de Chromecast", - }, - "field": {"checkbox": {"value": use_chromecast}}, - }, - { - "id": "external_metadata", - "label": { - "en": "Preview feature: Enable external Google Play metadata", - "de": "Vorschaufunktion: Aktiviere externe Google Play Metadaten", - "fr": "Fonctionnalité en aperçu: Activer les métadonnées externes de Google Play", - }, - "field": {"checkbox": {"value": use_external_metadata}}, - }, - { - "id": "adb", - "label": { - "en": "Preview feature: Enable ADB connection (for app list)", - "de": "Vorschaufunktion: Aktiviere ADB Verbindung (für App-Browsing)", - "fr": "Fonctionnalité en aperçu: Activer la connexion ADB (pour la navigation dans les applications)", - }, - "field": {"checkbox": {"value": use_adb}}, - }, __cfg_use_chromecast(use_chromecast), __cfg_chromecast_volume(use_chromecast_volume), __cfg_volume_step(volume_step), __cfg_external_metadata(use_external_metadata), + __cfg_adb(use_adb), ], ) case "reset": @@ -482,9 +458,9 @@ async def _handle_discovery(msg: UserDataResponse) -> RequestUserInput | SetupEr { "id": "external_metadata", "label": { - "en": "Preview feature: Enable external Google Play metadata", - "de": "Vorschaufunktion: Aktiviere externe Google Play Metadaten", - "fr": "Fonctionnalité en aperçu: Activer les métadonnées externes de Google Play", + "en": "Preview feature: Enable external metadata", + "de": "Vorschaufunktion: Aktiviere externe Metadaten", + "fr": "Fonctionnalité en aperçu: Activer les métadonnées externes", }, "field": {"checkbox": {"value": False}}, }, @@ -580,14 +556,167 @@ async def handle_device_choice(msg: UserDataResponse) -> RequestUserInput | Setu return _setup_error_from_device_state(_pairing_android_tv.state) +# async def handle_user_data_pin(msg: UserDataResponse) -> RequestUserInput | SetupComplete | SetupError: +# """ +# Process user data pairing pin response in a setup process. +# +# :param msg: response data from the requested user data +# :return: the setup action on how to continue. +# """ +# global _pairing_android_tv +# global _setup_step +# +# _LOG.debug("Entered handle_user_data_pin with msg: %s", msg) +# +# if _pairing_android_tv is None: +# _LOG.error("Can't handle pairing pin: no device instance! Aborting setup") +# return SetupError() +# +# _LOG.info("[%s] User has entered the PIN", _pairing_android_tv.log_id) +# +# res = await _pairing_android_tv.finish_pairing(msg.input_values["pin"]) +# _LOG.debug("[%s] finish_pairing result: %s", _pairing_android_tv.log_id, res) +# +# _pairing_android_tv.disconnect() +# _LOG.debug("[%s] Disconnected after pairing attempt", _pairing_android_tv.log_id) +# +# if res != ucapi.StatusCodes.OK: +# _LOG.warning("[%s] Pairing failed", _pairing_android_tv.log_id) +# return SetupError() +# +# _LOG.info("[%s] Pairing done, retrieving device information", _pairing_android_tv.log_id) +# timeout = int(tv.CONNECTION_TIMEOUT) +# res = ucapi.StatusCodes.SERVER_ERROR +# _device_info = None +# +# if await _pairing_android_tv.init(timeout): +# _LOG.debug("[%s] Initialization successful", _pairing_android_tv.log_id) +# if await _pairing_android_tv.connect(timeout): +# _LOG.debug("[%s] Connection successful", _pairing_android_tv.log_id) +# _device_info = _pairing_android_tv.device_info or {} +# _LOG.debug("[%s] Retrieved device info: %s", _pairing_android_tv.log_id, _device_info) +# +# if config.devices.assign_default_certs_to_device(_pairing_android_tv.identifier, True): +# res = ucapi.StatusCodes.OK +# _LOG.debug("[%s] Default certificates assigned successfully", _pairing_android_tv.log_id) +# +# _pairing_android_tv.disconnect() +# _LOG.debug("[%s] Disconnected after retrieving device information", _pairing_android_tv.log_id) +# +# if res != ucapi.StatusCodes.OK: +# state = _pairing_android_tv.state +# _LOG.info("[%s] Setup failed: %s (state=%s)", _pairing_android_tv.log_id, res, state) +# _pairing_android_tv = None +# return _setup_error_from_device_state(state) +# +# adb_apps = {} +# if _use_adb: +# _LOG.debug("ADB is enabled, proceeding with ADB setup") +# +# if not msg.input_values.get("adb", False): +# _LOG.error("ADB setup failed: 'adb' not found in input values") +# return SetupError() +# +# from adb_tv import adb_connect, get_installed_apps, is_authorised +# +# device_id = _pairing_android_tv.identifier +# ip_address = _pairing_android_tv.address +# _LOG.debug("Attempting ADB setup for device_id: %s, ip_address: %s", device_id, ip_address) +# +# adb_device = await adb_connect(device_id, ip_address) +# if not adb_device or not await is_authorised(adb_device): +# return SetupError(error_type=IntegrationSetupError.AUTHORIZATION_ERROR) +# +# _LOG.debug("ADB authorisation confirmed") +# adb_apps = await get_installed_apps(adb_device) +# _LOG.debug("Retrieved ADB apps: %s", adb_apps) +# await adb_device.close() +# +# from apps import Apps, IdMappings +# from external_metadata import get_app_metadata +# +# offline_friendly_names = set(IdMappings.values()) +# offline_package_ids = set(Apps.keys()) +# +# if _use_adb: +# filtered_adb_apps = {} +# for package, details in adb_apps.items(): +# if package in Apps or package in IdMappings: +# continue +# friendly = details.get("name", "") +# if friendly and friendly in offline_friendly_names: +# continue +# filtered_adb_apps[package] = details +# +# merged_apps = {**filtered_adb_apps, **Apps} +# else: +# merged_apps = Apps +# +# _LOG.debug("Merged apps (offline preferred): %s", merged_apps) +# +# app_entries = [] +# +# for package, details in merged_apps.items(): +# mapped_name = IdMappings.get(package) +# name = mapped_name or details.get("name", package) +# editable = False +# +# if _use_external_metadata and not mapped_name: +# try: +# metadata = await get_app_metadata(package) +# if metadata.get("name"): +# name = metadata["name"] +# editable = True +# except Exception as e: +# _LOG.warning("Metadata lookup failed for %s: %s", package, e) +# +# is_unfriendly = name.startswith("com.") or name.count('.') >= 2 +# app_entries.append((is_unfriendly, name.lower(), package, name, editable)) +# +# app_entries.sort() +# +# settings = [] +# +# for _, _, package, name, editable in app_entries: +# is_adb_only = _use_adb and package in adb_apps and package not in Apps +# +# settings.append({ +# "id": f"{package}_enabled", +# "label": { +# "en": name if not is_adb_only else f"{package}", +# "de": name if not is_adb_only else f"{package}", +# "fr": name if not is_adb_only else f"{package}", +# }, +# "field": {"checkbox": {"value": False}}, +# }) +# +# if is_adb_only and editable: +# settings.append({ +# "id": f"{package}_name", +# "label": { +# "en": f"Friendly name for {package}", +# "de": f"Anzeigename für {package}", +# "fr": f"Nom convivial pour {package}", +# }, +# "field": {"text": {"value": name}}, +# }) +# +# _setup_step = SetupSteps.APP_SELECTION +# return RequestUserInput( +# title={ +# "en": "Select visible apps", +# "de": "Wähle sichtbare Apps", +# "fr": "Sélectionnez les applications visibles", +# }, +# settings=settings, +# ) + async def handle_user_data_pin(msg: UserDataResponse) -> RequestUserInput | SetupComplete | SetupError: """ Process user data pairing pin response in a setup process. - Driver setup callback to provide requested user data during the setup process. - :param msg: response data from the requested user data - :return: the setup action on how to continue: SetupComplete if a valid Android TV device was chosen. + :return: the setup action on how to continue. """ global _pairing_android_tv global _setup_step @@ -606,14 +735,14 @@ async def handle_user_data_pin(msg: UserDataResponse) -> RequestUserInput | Setu _pairing_android_tv.disconnect() _LOG.debug("[%s] Disconnected after pairing attempt", _pairing_android_tv.log_id) - if res == ucapi.StatusCodes.OK: - _LOG.info("[%s] Pairing done, retrieving device information", _pairing_android_tv.log_id) - res = ucapi.StatusCodes.SERVER_ERROR - timeout = int(tv.CONNECTION_TIMEOUT) - _LOG.debug("[%s] Attempting to initialize and connect with timeout: %d", _pairing_android_tv.log_id, timeout) + if res != ucapi.StatusCodes.OK: + _LOG.warning("[%s] Pairing failed", _pairing_android_tv.log_id) + return SetupError() - _device_info = None + _LOG.info("[%s] Pairing done, retrieving device information", _pairing_android_tv.log_id) timeout = int(tv.CONNECTION_TIMEOUT) + res = ucapi.StatusCodes.SERVER_ERROR + _device_info = None if await _pairing_android_tv.init(timeout): _LOG.debug("[%s] Initialization successful", _pairing_android_tv.log_id) @@ -635,11 +764,12 @@ async def handle_user_data_pin(msg: UserDataResponse) -> RequestUserInput | Setu _pairing_android_tv = None return _setup_error_from_device_state(state) + adb_apps = {} if _use_adb: _LOG.debug("ADB is enabled, proceeding with ADB setup") if not msg.input_values.get("adb", False): - _LOG.error("ADB setup failed: 'use_adb' not found in input values") + _LOG.error("ADB setup failed: 'adb' not found in input values") return SetupError() from adb_tv import adb_connect, get_installed_apps, is_authorised @@ -649,53 +779,41 @@ async def handle_user_data_pin(msg: UserDataResponse) -> RequestUserInput | Setu _LOG.debug("Attempting ADB setup for device_id: %s, ip_address: %s", device_id, ip_address) adb_device = await adb_connect(device_id, ip_address) - if not adb_device: - return SetupError(error_type=IntegrationSetupError.AUTHORIZATION_ERROR) - - if not await is_authorised(adb_device): + if not adb_device or not await is_authorised(adb_device): return SetupError(error_type=IntegrationSetupError.AUTHORIZATION_ERROR) _LOG.debug("ADB authorisation confirmed") + adb_apps = await get_installed_apps(adb_device) + _LOG.debug("Retrieved ADB apps: %s", adb_apps) + await adb_device.close() from apps import Apps, IdMappings from external_metadata import get_app_metadata - # STEP 1: Resolve canonical friendly names used in offline mappings offline_friendly_names = set(IdMappings.values()) offline_package_ids = set(Apps.keys()) if _use_adb: - adb_apps = await get_installed_apps(adb_device) - _LOG.debug("Retrieved ADB apps: %s", adb_apps) - await adb_device.close() - - # STEP 2: Remove ADB entries that duplicate an offline friendly name filtered_adb_apps = {} for package, details in adb_apps.items(): - if package in Apps: - continue # Already covered offline (exact match) - if package in IdMappings: - continue # Already has a mapped friendly name offline + if package in Apps or package in IdMappings: + continue friendly = details.get("name", "") - if friendly and friendly in IdMappings.values(): - continue # Another offline app already uses this friendly name + if friendly and friendly in offline_friendly_names: + continue filtered_adb_apps[package] = details - - # STEP 3: Merge with Apps, giving priority to offline Apps merged_apps = {**filtered_adb_apps, **Apps} else: merged_apps = Apps _LOG.debug("Merged apps (offline preferred): %s", merged_apps) - # STEP 4: Build app list for rendering app_entries = [] for package, details in merged_apps.items(): mapped_name = IdMappings.get(package) - static_name = details.get("name", package) - name = mapped_name or static_name + name = mapped_name or details.get("name", package) editable = False if _use_external_metadata and not mapped_name: @@ -703,46 +821,40 @@ async def handle_user_data_pin(msg: UserDataResponse) -> RequestUserInput | Setu metadata = await get_app_metadata(package) if metadata.get("name"): name = metadata["name"] - editable = True # Only editable if name came from metadata + editable = True except Exception as e: _LOG.warning("Metadata lookup failed for %s: %s", package, e) is_unfriendly = name.startswith("com.") or name.count('.') >= 2 app_entries.append((is_unfriendly, name.lower(), package, name, editable)) - # STEP 5: Sort by friendly/unfriendly then alphabetically app_entries.sort() settings = [] - # STEP 6: Output settings based on filtered and sorted entries for _, _, package, name, editable in app_entries: is_adb_only = _use_adb and package in adb_apps and package not in Apps - settings.append( - { - "id": f"{package}_enabled", + settings.append({ + "id": f"{package}_enabled", + "label": { + "en": name if not is_adb_only else f"{package}", + "de": name if not is_adb_only else f"{package}", + "fr": name if not is_adb_only else f"{package}", + }, + "field": {"checkbox": {"value": False}}, + }) + + if is_adb_only or editable: + settings.append({ + "id": f"{package}_name", "label": { - "en": name if not is_adb_only else f"{package}", - "de": name if not is_adb_only else f"{package}", - "fr": name if not is_adb_only else f"{package}", + "en": f"Friendly name for {package}", + "de": f"Anzeigename für {package}", + "fr": f"Nom convivial pour {package}", }, - "field": {"checkbox": {"value": False}}, - } - ) - - if is_adb_only and editable: - settings.append( - { - "id": f"{package}_name", - "label": { - "en": f"Friendly name for {package}", - "de": f"Anzeigename für {package}", - "fr": f"Nom convivial pour {package}", - }, - "field": {"text": {"value": name}}, - } - ) + "field": {"text": {"value": name}}, + }) _setup_step = SetupSteps.APP_SELECTION return RequestUserInput( @@ -798,8 +910,6 @@ async def handle_app_selection(msg: UserDataResponse) -> SetupComplete | SetupEr use_external_metadata=_use_external_metadata, use_chromecast=_use_chromecast, use_chromecast_volume=_use_chromecast_volume, - manufacturer=device_info.get("manufacturer", ""), - model=device_info.get("model", ""), volume_step=_volume_step, use_adb=_use_adb, ) @@ -913,9 +1023,20 @@ def __cfg_external_metadata(enabled: bool): return { "id": "external_metadata", "label": { - "en": "Preview feature: Enable external Google Play metadata", - "de": "Vorschaufunktion: Aktiviere externe Google Play Metadaten", - "fr": "Fonctionnalité en aperçu: Activer les métadonnées externes de Google Play", + "en": "Preview feature: Enable external metadata", + "de": "Vorschaufunktion: Aktiviere externe Metadaten", + "fr": "Fonctionnalité en aperçu: Activer les métadonnées externes", }, "field": {"checkbox": {"value": enabled}}, } + +def __cfg_adb(enabled: bool): + return { + "id": "adb", + "label": { + "en": "Preview feature: Enable ADB connection (for app list)", + "de": "Vorschaufunktion: Aktiviere ADB Verbindung (für App-Browsing)", + "fr": "Fonctionnalité en aperçu: Activer la connexion ADB (pour la navigation dans les applications)", + }, + "field": {"checkbox": {"value": enabled}}, + } \ No newline at end of file From f9cb0dbb7843f04b29b6c977f4946d30e8cd73c2 Mon Sep 17 00:00:00 2001 From: Thomas Mason Date: Thu, 15 May 2025 16:36:00 +0100 Subject: [PATCH 42/46] update --- requirements.txt | 2 +- src/setup_flow.py | 28 +--------------------------- 2 files changed, 2 insertions(+), 28 deletions(-) diff --git a/requirements.txt b/requirements.txt index 81af407..dedf3b5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,5 +8,5 @@ pychromecast~=14.0.7 httpx~=0.28.1 sanitize-filename~=1.2.0 async_timeout~=5.0.1 -adb-shell==0.4.4 +adb_shell==0.4.4 simple-justwatch-python-api>=0.16 \ No newline at end of file diff --git a/src/setup_flow.py b/src/setup_flow.py index 8f0a911..c2dfc93 100644 --- a/src/setup_flow.py +++ b/src/setup_flow.py @@ -446,37 +446,11 @@ async def _handle_discovery(msg: UserDataResponse) -> RequestUserInput | SetupEr "fr": "Choisir votre Android TV", }, }, - { - "id": "chromecast", - "label": { - "en": "Preview feature: Enable Chromecast features", - "de": "Vorschaufunktion: Aktiviere Chromecast-Features", - "fr": "Fonctionnalité en aperçu: Activer les fonctionnalités de Chromecast", - }, - "field": {"checkbox": {"value": False}}, - }, - { - "id": "external_metadata", - "label": { - "en": "Preview feature: Enable external metadata", - "de": "Vorschaufunktion: Aktiviere externe Metadaten", - "fr": "Fonctionnalité en aperçu: Activer les métadonnées externes", - }, - "field": {"checkbox": {"value": False}}, - }, - { - "id": "adb", - "label": { - "en": "Preview feature: Enable ADB connection (for app list)", - "de": "Vorschaufunktion: Aktiviere ADB Verbindung (für App-Browsing)", - "fr": "Fonctionnalité en aperçu: Activer la connexion ADB (pour la navigation dans les applications)", - }, - "field": {"checkbox": {"value": False}}, - }, __cfg_use_chromecast(False), __cfg_chromecast_volume(False), __cfg_volume_step(10), __cfg_external_metadata(False), + __cfg_adb(False), ], ) From 723c07f141bdbb3f5d9842b0166a18872138f3a2 Mon Sep 17 00:00:00 2001 From: Thomas Mason Date: Thu, 15 May 2025 16:36:26 +0100 Subject: [PATCH 43/46] update --- src/setup_flow.py | 156 ---------------------------------------------- 1 file changed, 156 deletions(-) diff --git a/src/setup_flow.py b/src/setup_flow.py index c2dfc93..ad18cdd 100644 --- a/src/setup_flow.py +++ b/src/setup_flow.py @@ -529,162 +529,6 @@ async def handle_device_choice(msg: UserDataResponse) -> RequestUserInput | Setu return _setup_error_from_device_state(_pairing_android_tv.state) - -# async def handle_user_data_pin(msg: UserDataResponse) -> RequestUserInput | SetupComplete | SetupError: -# """ -# Process user data pairing pin response in a setup process. -# -# :param msg: response data from the requested user data -# :return: the setup action on how to continue. -# """ -# global _pairing_android_tv -# global _setup_step -# -# _LOG.debug("Entered handle_user_data_pin with msg: %s", msg) -# -# if _pairing_android_tv is None: -# _LOG.error("Can't handle pairing pin: no device instance! Aborting setup") -# return SetupError() -# -# _LOG.info("[%s] User has entered the PIN", _pairing_android_tv.log_id) -# -# res = await _pairing_android_tv.finish_pairing(msg.input_values["pin"]) -# _LOG.debug("[%s] finish_pairing result: %s", _pairing_android_tv.log_id, res) -# -# _pairing_android_tv.disconnect() -# _LOG.debug("[%s] Disconnected after pairing attempt", _pairing_android_tv.log_id) -# -# if res != ucapi.StatusCodes.OK: -# _LOG.warning("[%s] Pairing failed", _pairing_android_tv.log_id) -# return SetupError() -# -# _LOG.info("[%s] Pairing done, retrieving device information", _pairing_android_tv.log_id) -# timeout = int(tv.CONNECTION_TIMEOUT) -# res = ucapi.StatusCodes.SERVER_ERROR -# _device_info = None -# -# if await _pairing_android_tv.init(timeout): -# _LOG.debug("[%s] Initialization successful", _pairing_android_tv.log_id) -# if await _pairing_android_tv.connect(timeout): -# _LOG.debug("[%s] Connection successful", _pairing_android_tv.log_id) -# _device_info = _pairing_android_tv.device_info or {} -# _LOG.debug("[%s] Retrieved device info: %s", _pairing_android_tv.log_id, _device_info) -# -# if config.devices.assign_default_certs_to_device(_pairing_android_tv.identifier, True): -# res = ucapi.StatusCodes.OK -# _LOG.debug("[%s] Default certificates assigned successfully", _pairing_android_tv.log_id) -# -# _pairing_android_tv.disconnect() -# _LOG.debug("[%s] Disconnected after retrieving device information", _pairing_android_tv.log_id) -# -# if res != ucapi.StatusCodes.OK: -# state = _pairing_android_tv.state -# _LOG.info("[%s] Setup failed: %s (state=%s)", _pairing_android_tv.log_id, res, state) -# _pairing_android_tv = None -# return _setup_error_from_device_state(state) -# -# adb_apps = {} -# if _use_adb: -# _LOG.debug("ADB is enabled, proceeding with ADB setup") -# -# if not msg.input_values.get("adb", False): -# _LOG.error("ADB setup failed: 'adb' not found in input values") -# return SetupError() -# -# from adb_tv import adb_connect, get_installed_apps, is_authorised -# -# device_id = _pairing_android_tv.identifier -# ip_address = _pairing_android_tv.address -# _LOG.debug("Attempting ADB setup for device_id: %s, ip_address: %s", device_id, ip_address) -# -# adb_device = await adb_connect(device_id, ip_address) -# if not adb_device or not await is_authorised(adb_device): -# return SetupError(error_type=IntegrationSetupError.AUTHORIZATION_ERROR) -# -# _LOG.debug("ADB authorisation confirmed") -# adb_apps = await get_installed_apps(adb_device) -# _LOG.debug("Retrieved ADB apps: %s", adb_apps) -# await adb_device.close() -# -# from apps import Apps, IdMappings -# from external_metadata import get_app_metadata -# -# offline_friendly_names = set(IdMappings.values()) -# offline_package_ids = set(Apps.keys()) -# -# if _use_adb: -# filtered_adb_apps = {} -# for package, details in adb_apps.items(): -# if package in Apps or package in IdMappings: -# continue -# friendly = details.get("name", "") -# if friendly and friendly in offline_friendly_names: -# continue -# filtered_adb_apps[package] = details -# -# merged_apps = {**filtered_adb_apps, **Apps} -# else: -# merged_apps = Apps -# -# _LOG.debug("Merged apps (offline preferred): %s", merged_apps) -# -# app_entries = [] -# -# for package, details in merged_apps.items(): -# mapped_name = IdMappings.get(package) -# name = mapped_name or details.get("name", package) -# editable = False -# -# if _use_external_metadata and not mapped_name: -# try: -# metadata = await get_app_metadata(package) -# if metadata.get("name"): -# name = metadata["name"] -# editable = True -# except Exception as e: -# _LOG.warning("Metadata lookup failed for %s: %s", package, e) -# -# is_unfriendly = name.startswith("com.") or name.count('.') >= 2 -# app_entries.append((is_unfriendly, name.lower(), package, name, editable)) -# -# app_entries.sort() -# -# settings = [] -# -# for _, _, package, name, editable in app_entries: -# is_adb_only = _use_adb and package in adb_apps and package not in Apps -# -# settings.append({ -# "id": f"{package}_enabled", -# "label": { -# "en": name if not is_adb_only else f"{package}", -# "de": name if not is_adb_only else f"{package}", -# "fr": name if not is_adb_only else f"{package}", -# }, -# "field": {"checkbox": {"value": False}}, -# }) -# -# if is_adb_only and editable: -# settings.append({ -# "id": f"{package}_name", -# "label": { -# "en": f"Friendly name for {package}", -# "de": f"Anzeigename für {package}", -# "fr": f"Nom convivial pour {package}", -# }, -# "field": {"text": {"value": name}}, -# }) -# -# _setup_step = SetupSteps.APP_SELECTION -# return RequestUserInput( -# title={ -# "en": "Select visible apps", -# "de": "Wähle sichtbare Apps", -# "fr": "Sélectionnez les applications visibles", -# }, -# settings=settings, -# ) - async def handle_user_data_pin(msg: UserDataResponse) -> RequestUserInput | SetupComplete | SetupError: """ Process user data pairing pin response in a setup process. From cce5d6fc4ae23488f2d6da85a1d220215cd17774 Mon Sep 17 00:00:00 2001 From: Thomas Mason Date: Thu, 15 May 2025 17:19:57 +0100 Subject: [PATCH 44/46] update --- src/tv.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/tv.py b/src/tv.py index 7c76cbe..3e66962 100644 --- a/src/tv.py +++ b/src/tv.py @@ -983,10 +983,9 @@ async def _update_media_status(self): if metadata: external_name = metadata.get("name") external_icon = metadata.get("icon") - if _LOG.isEnabledFor(logging.DEBUG): - _LOG.debug("External app metadata: %s", filter_data_img_properties(metadata)) + _LOG.debug("External app metadata: %s", filter_data_img_properties(metadata)) - app_name = external_name or offline_name or offline_match or current_app + app_name = (external_name or offline_name or offline_match or current_app) self._media_app = app_name self._app_image_url = external_icon or "" From 6c38afc2ad2a07efcb253b2e16e32b870a32f44f Mon Sep 17 00:00:00 2001 From: Thomas Mason Date: Thu, 15 May 2025 17:54:41 +0100 Subject: [PATCH 45/46] adding cache --- src/external_metadata.py | 81 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 76 insertions(+), 5 deletions(-) diff --git a/src/external_metadata.py b/src/external_metadata.py index e4b9a2d..7dc0031 100644 --- a/src/external_metadata.py +++ b/src/external_metadata.py @@ -108,6 +108,60 @@ def resize_image() -> str: return None +# 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_name, MediaImage): +# icon_name = icon_name.url +# +# if isinstance(icon_name, str) and icon_name.startswith("data:image"): +# _LOG.debug("Icon is already a data URI") +# return icon_name +# +# _LOG.debug("Encoding icon to data URI: %s", icon_name) +# try: +# 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_path(icon_name) +# if not icon_path.exists(): +# raise FileNotFoundError(f"Icon not found: {icon_name}") +# with open(icon_path, "rb") as f: +# img = Image.open(f) +# 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}" +# +# return await asyncio.to_thread(load_and_encode) +# +# except Exception as e: +# _LOG.warning("Failed to encode icon to base64 for %s: %s", icon_name, e) +# return "" + + +_ENCODED_ICON_CACHE: dict[str, str] = {} +_MISSING_ICON_CACHE: set[str] = set() + async def encode_icon_to_data_uri(icon_name: str) -> str: """ Encode an image from a local file path or remote URL. @@ -121,7 +175,15 @@ async def encode_icon_to_data_uri(icon_name: str) -> str: _LOG.debug("Icon is already a data URI") return icon_name + if icon_name in _ENCODED_ICON_CACHE: + return _ENCODED_ICON_CACHE[icon_name] + + if icon_name in _MISSING_ICON_CACHE: + _LOG.debug("Skipping encoding; previously failed: %s", icon_name) + return "" + _LOG.debug("Encoding icon to data URI: %s", icon_name) + try: if _is_url(icon_name): async with httpx.AsyncClient() as client: @@ -137,12 +199,18 @@ def encode_image() -> str: encoded = base64.b64encode(buffer.getvalue()).decode("utf-8") return f"data:image/png;base64,{encoded}" - return await asyncio.to_thread(encode_image) + encoded = await asyncio.to_thread(encode_image) + _ENCODED_ICON_CACHE[icon_name] = encoded + return encoded + + # Local file path + icon_path = _get_icon_path(icon_name) + if not icon_path.exists(): + _LOG.warning("Icon not found on disk: %s", icon_path) + _MISSING_ICON_CACHE.add(icon_name) + return "" def load_and_encode() -> str: - icon_path = _get_icon_path(icon_name) - if not icon_path.exists(): - raise FileNotFoundError(f"Icon not found: {icon_name}") with open(icon_path, "rb") as f: img = Image.open(f) img.load() @@ -152,10 +220,13 @@ def load_and_encode() -> str: encoded = base64.b64encode(buffer.getvalue()).decode("utf-8") return f"data:image/png;base64,{encoded}" - return await asyncio.to_thread(load_and_encode) + encoded = await asyncio.to_thread(load_and_encode) + _ENCODED_ICON_CACHE[icon_name] = encoded + return encoded except Exception as e: _LOG.warning("Failed to encode icon to base64 for %s: %s", icon_name, e) + _MISSING_ICON_CACHE.add(icon_name) return "" From 77c305744197046acb7baaeb4c7cd7f9056bd22b Mon Sep 17 00:00:00 2001 From: Markus Zehnder Date: Mon, 2 Jun 2025 12:14:09 +0200 Subject: [PATCH 46/46] fix: linting & common fixes Run isort, black etc and a few manual fixes. There are still a few Pylint issues left that need to be addressed after testing. --- src/adb_tv.py | 6 +- src/external_metadata.py | 67 ++++++++++++---------- src/profiles.py | 15 ++--- src/setup_flow.py | 69 +++++++++++----------- src/tv.py | 120 +++++++++++++++++++++++---------------- 5 files changed, 150 insertions(+), 127 deletions(-) diff --git a/src/adb_tv.py b/src/adb_tv.py index 88e038c..867c583 100644 --- a/src/adb_tv.py +++ b/src/adb_tv.py @@ -1,10 +1,10 @@ """ This module provides utilities for interacting with Android TVs via ADB (Android Debug Bridge). + It includes functions for managing ADB keys, connecting to devices, retrieving installed apps, and verifying device authorization. """ -import asyncio import os from pathlib import Path from typing import Dict, Optional @@ -47,9 +47,9 @@ def load_or_generate_adb_keys(device_id: str) -> PythonRSASigner: if not priv_path.exists() or not pub_path.exists(): keygen(str(priv_path)) - with open(priv_path) as f: + with open(priv_path, encoding="utf-8") as f: priv = f.read() - with open(pub_path) as f: + with open(pub_path, encoding="utf-8") as f: pub = f.read() return PythonRSASigner(pub, priv) diff --git a/src/external_metadata.py b/src/external_metadata.py index 7dc0031..763ace2 100644 --- a/src/external_metadata.py +++ b/src/external_metadata.py @@ -9,12 +9,11 @@ import base64 import json import logging -import os +import re from io import BytesIO from pathlib import Path from typing import Dict -from urllib.parse import urlparse, quote -import re +from urllib.parse import quote, urlparse import google_play_scraper import httpx @@ -22,9 +21,10 @@ from PIL.Image import Resampling from pychromecast.controllers.media import MediaImage from sanitize_filename import sanitize -from config import _get_config_root, _get_data_root from simplejustwatchapi import justwatch +from config import _get_config_root, _get_data_root + _LOG = logging.getLogger(__name__) CACHE_ROOT = "external_cache" @@ -58,6 +58,7 @@ def _get_icon_path(icon_name: str) -> Path: return _get_config_root() / "icons" / sanitize(icon_name[9:]) return _get_icon_dir() / sanitize(icon_name) + # Cache Management def _load_cache() -> Dict[str, Dict[str, str]]: path = _get_metadata_file_path() @@ -162,6 +163,7 @@ def resize_image() -> str: _ENCODED_ICON_CACHE: dict[str, str] = {} _MISSING_ICON_CACHE: set[str] = set() + async def encode_icon_to_data_uri(icon_name: str) -> str: """ Encode an image from a local file path or remote URL. @@ -291,11 +293,10 @@ async def get_app_metadata(package_id: str) -> Dict[str, str]: return {"name": "", "icon": ""} -async def youtube_search(query: str, limit: int = 1): +async def youtube_search(query: str): + """Search for poster images using YouTube.""" url = f"https://www.youtube.com/results?search_query={quote(query)}" - headers = { - "User-Agent": "Mozilla/5.0" - } + headers = {"User-Agent": "Mozilla/5.0"} with httpx.Client(headers=headers, timeout=10) as client: response = client.get(url) @@ -309,11 +310,9 @@ async def youtube_search(query: str, limit: int = 1): data = json.loads(match.group(1)) try: - items = ( - data["contents"]["twoColumnSearchResultsRenderer"] - ["primaryContents"]["sectionListRenderer"] - ["contents"][0]["itemSectionRenderer"]["contents"] - ) + items = data["contents"]["twoColumnSearchResultsRenderer"]["primaryContents"]["sectionListRenderer"][ + "contents" + ][0]["itemSectionRenderer"]["contents"] except (KeyError, IndexError): raise RuntimeError("Could not parse YouTube data structure") @@ -326,27 +325,35 @@ async def youtube_search(query: str, limit: int = 1): return None -async def search_poster_justwatch(query: str, country: str = "GB", limit: int = 1) -> list[dict]: - """Search for poster images using JustWatch API.""" - response = justwatch.search(query, country, 'en', count=1, best_only=True) +async def search_poster_justwatch(query: str, country: str = "GB") -> str | None: + """Search for poster images using JustWatch API.""" + response = justwatch.search(query, country, "en", count=1, best_only=True) if not response[0].poster: return None - else: - poster_url = response[0].poster - if poster_url: - return poster_url + poster_url = response[0].poster + + if poster_url: + return poster_url return None -async def get_best_artwork(title: str, artist: str = None, current_package: str = None) -> Dict[str, str] | bool: - _LOG.debug("Resolving best artwork for title='%s', artist='%s', current_package='%s'", title, artist, current_package) + +async def get_best_artwork(title: str, artist: str = None, current_package: str = None) -> str | None: + """Get artwork for a TV show or movie based on current app and title/artist.""" + _LOG.debug( + "Resolving best artwork for title='%s', artist='%s', current_package='%s'", title, artist, current_package + ) search_query = f"{title} - {artist}" if artist else title - if current_package in ["com.google.android.youtube.tv", "com.liskovsoft.videomanager", "com.teamsmart.videomanager.tv"]: + if current_package in [ + "com.google.android.youtube.tv", + "com.liskovsoft.videomanager", + "com.teamsmart.videomanager.tv", + ]: _LOG.debug("YouTube detected. Searching for artwork.") @@ -355,19 +362,17 @@ async def get_best_artwork(title: str, artist: str = None, current_package: str if youtube: _LOG.debug("Artwork result:\n%s", json.dumps(youtube, indent=2)) return youtube - else: - _LOG.debug("No artwork found from YouTube search.") + _LOG.debug("No artwork found from YouTube search.") else: _LOG.debug("Non-YouTube package detected. Searching for artwork.") - justwatch = await search_poster_justwatch(search_query) + justwatch_poster = await search_poster_justwatch(search_query) - if justwatch: + if justwatch_poster: _LOG.debug("Artwork result:\n%s", json.dumps(justwatch, indent=2)) - return justwatch - else: - _LOG.debug("No artwork found from JustWatch search.") + return justwatch_poster + _LOG.debug("No artwork found from JustWatch search.") _LOG.debug("No artwork source applicable. Returning False.") return None @@ -377,4 +382,4 @@ async def get_best_artwork(title: str, artist: str = None, current_package: str # posters = await get_best_artwork("Episode 1", "Breaking Bad", "com.plexapp.android") # print(posters) # -# asyncio.run(test()) \ No newline at end of file +# asyncio.run(test()) diff --git a/src/profiles.py b/src/profiles.py index f655c1d..b868963 100644 --- a/src/profiles.py +++ b/src/profiles.py @@ -12,8 +12,10 @@ import json import logging import os +import string from dataclasses import dataclass from enum import IntEnum +from typing import Any from ucapi import media_player @@ -260,7 +262,6 @@ def match(self, manufacturer: str, model: str, use_chromecast: bool) -> Profile: select_profile = copy.copy(select_profile) select_profile.features.extend(CHROMECAST_FEATURES) - import string for char in string.ascii_uppercase + " ": # Use 'SPACE' as the key name for the space character key = "SPACE" if char == " " else char @@ -271,13 +272,13 @@ def match(self, manufacturer: str, model: str, use_chromecast: bool) -> Profile: # Map the command: space gets ' ', others get lowercase letter command_value = " " if char == " " else char.lower() - select_profile.command_map[command_name] = Command(command_value, 'TEXT') + select_profile.command_map[command_name] = Command(command_value, "TEXT") - select_profile.simple_commands.append('TEXT_BACKSPACE') - select_profile.command_map['TEXT_BACKSPACE'] = Command('DEL', KeyPress.SHORT) + select_profile.simple_commands.append("TEXT_BACKSPACE") + select_profile.command_map["TEXT_BACKSPACE"] = Command("DEL", KeyPress.SHORT) - select_profile.simple_commands.append('TEXT_ENTER') - select_profile.command_map['TEXT_ENTER'] = Command('ENTER', KeyPress.SHORT) + select_profile.simple_commands.append("TEXT_ENTER") + select_profile.command_map["TEXT_ENTER"] = Command("ENTER", KeyPress.SHORT) return select_profile @@ -297,7 +298,7 @@ def _str_to_feature(value: str) -> media_player.Features | None: return None -def _convert_command_map(values: dict[str, any]) -> dict[str, Command]: +def _convert_command_map(values: dict[str, Any]) -> dict[str, Command]: cmd_map = {} for key, value in values.items(): try: diff --git a/src/setup_flow.py b/src/setup_flow.py index ad18cdd..57bddd1 100644 --- a/src/setup_flow.py +++ b/src/setup_flow.py @@ -6,6 +6,7 @@ """ import asyncio +import json import logging import os from enum import IntEnum @@ -27,7 +28,10 @@ import config import discover import tv +from adb_tv import adb_connect, get_installed_apps, is_authorised +from apps import Apps, IdMappings from config import AtvDevice, _get_config_root +from external_metadata import get_app_metadata _LOG = logging.getLogger(__name__) @@ -320,9 +324,7 @@ async def handle_configuration_mode( _setup_step = SetupSteps.RECONFIGURE _reconfigured_device = selected_device use_chromecast = selected_device.use_chromecast if selected_device.use_chromecast else False - use_adb = ( - selected_device.use_adb if selected_device.use_adb else False - ) + use_adb = selected_device.use_adb if selected_device.use_adb else False use_chromecast_volume = ( selected_device.use_chromecast_volume if selected_device.use_chromecast_volume else False ) @@ -529,6 +531,7 @@ async def handle_device_choice(msg: UserDataResponse) -> RequestUserInput | Setu return _setup_error_from_device_state(_pairing_android_tv.state) + async def handle_user_data_pin(msg: UserDataResponse) -> RequestUserInput | SetupComplete | SetupError: """ Process user data pairing pin response in a setup process. @@ -590,8 +593,6 @@ async def handle_user_data_pin(msg: UserDataResponse) -> RequestUserInput | Setu _LOG.error("ADB setup failed: 'adb' not found in input values") return SetupError() - from adb_tv import adb_connect, get_installed_apps, is_authorised - device_id = _pairing_android_tv.identifier ip_address = _pairing_android_tv.address _LOG.debug("Attempting ADB setup for device_id: %s, ip_address: %s", device_id, ip_address) @@ -605,11 +606,8 @@ async def handle_user_data_pin(msg: UserDataResponse) -> RequestUserInput | Setu _LOG.debug("Retrieved ADB apps: %s", adb_apps) await adb_device.close() - from apps import Apps, IdMappings - from external_metadata import get_app_metadata - offline_friendly_names = set(IdMappings.values()) - offline_package_ids = set(Apps.keys()) + # offline_package_ids = set(Apps.keys()) if _use_adb: filtered_adb_apps = {} @@ -643,7 +641,7 @@ async def handle_user_data_pin(msg: UserDataResponse) -> RequestUserInput | Setu except Exception as e: _LOG.warning("Metadata lookup failed for %s: %s", package, e) - is_unfriendly = name.startswith("com.") or name.count('.') >= 2 + is_unfriendly = name.startswith("com.") or name.count(".") >= 2 app_entries.append((is_unfriendly, name.lower(), package, name, editable)) app_entries.sort() @@ -653,26 +651,30 @@ async def handle_user_data_pin(msg: UserDataResponse) -> RequestUserInput | Setu for _, _, package, name, editable in app_entries: is_adb_only = _use_adb and package in adb_apps and package not in Apps - settings.append({ - "id": f"{package}_enabled", - "label": { - "en": name if not is_adb_only else f"{package}", - "de": name if not is_adb_only else f"{package}", - "fr": name if not is_adb_only else f"{package}", - }, - "field": {"checkbox": {"value": False}}, - }) - - if is_adb_only or editable: - settings.append({ - "id": f"{package}_name", + settings.append( + { + "id": f"{package}_enabled", "label": { - "en": f"Friendly name for {package}", - "de": f"Anzeigename für {package}", - "fr": f"Nom convivial pour {package}", + "en": name if not is_adb_only else f"{package}", + "de": name if not is_adb_only else f"{package}", + "fr": name if not is_adb_only else f"{package}", }, - "field": {"text": {"value": name}}, - }) + "field": {"checkbox": {"value": False}}, + } + ) + + if is_adb_only or editable: + settings.append( + { + "id": f"{package}_name", + "label": { + "en": f"Friendly name for {package}", + "de": f"Anzeigename für {package}", + "fr": f"Nom convivial pour {package}", + }, + "field": {"text": {"value": name}}, + } + ) _setup_step = SetupSteps.APP_SELECTION return RequestUserInput( @@ -687,14 +689,6 @@ async def handle_user_data_pin(msg: UserDataResponse) -> RequestUserInput | Setu async def handle_app_selection(msg: UserDataResponse) -> SetupComplete | SetupError: global _pairing_android_tv - global _use_chromecast - global _use_external_metadata - global _use_adb - global _device_info - - import json - - from apps import Apps # offline apps selected_apps = {} @@ -848,6 +842,7 @@ def __cfg_external_metadata(enabled: bool): "field": {"checkbox": {"value": enabled}}, } + def __cfg_adb(enabled: bool): return { "id": "adb", @@ -857,4 +852,4 @@ def __cfg_adb(enabled: bool): "fr": "Fonctionnalité en aperçu: Activer la connexion ADB (pour la navigation dans les applications)", }, "field": {"checkbox": {"value": enabled}}, - } \ No newline at end of file + } diff --git a/src/tv.py b/src/tv.py index 3e66962..8a609b1 100644 --- a/src/tv.py +++ b/src/tv.py @@ -51,7 +51,11 @@ import discover import inputs from config import AtvDevice, _get_config_root -from external_metadata import encode_icon_to_data_uri, get_app_metadata, get_best_artwork +from external_metadata import ( + encode_icon_to_data_uri, + get_app_metadata, + get_best_artwork, +) from profiles import KeyPress, Profile from util import filter_data_img_properties @@ -603,13 +607,13 @@ def disconnect(self) -> None: # Callbacks - def _is_on_updated(self, is_on: bool) -> None: + def _is_on_updated(self, _is_on: bool) -> None: if not self._loop or not self._loop.is_running(): _LOG.warning("[%s] No running event loop for power update", self.log_id) return asyncio.run_coroutine_threadsafe(self._update_media_status(), self._loop) - def _current_app_updated(self, current_app: str) -> None: + def _current_app_updated(self, _current_app: str) -> None: if not self._loop or not self._loop.is_running(): _LOG.warning("[%s] No running event loop for app update", self.log_id) return @@ -644,7 +648,6 @@ def _update_app_list(self) -> None: _LOG.warning("Failed to read apps list from %s: %s", apps_file, e) else: _LOG.info("No saved app list found for %s, falling back to default", self._identifier) - import apps # fall back to static apps source_list.extend(apps.Apps.keys()) @@ -701,6 +704,8 @@ async def turn_off(self) -> ucapi.StatusCodes: async def select_source(self, source: str) -> ucapi.StatusCodes: """ + Launch an app on the Android TV or select in input source on a TV running Android TV. + Select a given source, either a user-defined app (from JSON), an input source (KeyCode), or directly by app-link / id. @@ -749,13 +754,13 @@ async def _send_command(self, keycode: int | str, action: KeyPress = KeyPress.SH :return: OK if scheduled to be sent, other error code in case of an error """ # noqa - if action == 'TEXT': + if action == "TEXT": # Special handling for text input if not isinstance(keycode, str): _LOG.error("[%s] Cannot send command, invalid key_code: %s", self.log_id, keycode) return ucapi.StatusCodes.BAD_REQUEST - if keycode in ('DEL', 'ENTER'): - self._atv.send_key_command(keycode, 'SHORT') + if keycode in ("DEL", "ENTER"): + self._atv.send_key_command(keycode, "SHORT") else: self._atv.send_text(keycode) return ucapi.StatusCodes.OK @@ -804,6 +809,7 @@ def new_connection_status(self, status: ConnectionStatus) -> None: _LOG.info("[%s] Received Chromecast connection status : %s", self.log_id, status) def new_media_status(self, status: MediaStatus) -> None: + """Receive new media status event from Google cast.""" if not self._loop or not self._loop.is_running(): _LOG.warning("[%s] No running event loop for Chromecast status", self.log_id) return @@ -926,24 +932,30 @@ async def _update_media_status(self): # Device is OFF if not is_on: self._chromecast_metadata_active = False - update.update({ - MediaAttr.STATE: media_player.States.OFF.value, - MediaAttr.MEDIA_TITLE: "", - MediaAttr.SOURCE: "", - MediaAttr.MEDIA_IMAGE_URL: "", - }) + update.update( + { + MediaAttr.STATE: media_player.States.OFF.value, + MediaAttr.MEDIA_TITLE: "", + MediaAttr.SOURCE: "", + MediaAttr.MEDIA_IMAGE_URL: "", + } + ) self.events.emit(Events.UPDATE, self._identifier, update) return # Device on homescreen or standby app if homescreen_app or standby_app: self._chromecast_metadata_active = False - update.update({ - MediaAttr.STATE: media_player.States.ON.value if homescreen_app else media_player.States.STANDBY.value, - MediaAttr.SOURCE: "", - MediaAttr.MEDIA_TITLE: "", - MediaAttr.MEDIA_IMAGE_URL: "", - }) + update.update( + { + MediaAttr.STATE: ( + media_player.States.ON.value if homescreen_app else media_player.States.STANDBY.value + ), + MediaAttr.SOURCE: "", + MediaAttr.MEDIA_TITLE: "", + MediaAttr.MEDIA_IMAGE_URL: "", + } + ) self.events.emit(Events.UPDATE, self._identifier, update) return @@ -960,19 +972,17 @@ async def _update_media_status(self): if self._device_config.use_chromecast and self._chromecast: chromecast_status = self._chromecast.media_controller.status chromecast_active = ( - self._chromecast.socket_client.is_connected and - chromecast_status and - chromecast_status.player_state in chromecast_playing_states and - (chromecast_status.title or chromecast_status.images) + self._chromecast.socket_client.is_connected + and chromecast_status + and chromecast_status.player_state in chromecast_playing_states + and (chromecast_status.title or chromecast_status.images) ) self._chromecast_metadata_active = chromecast_active # App metadata from offline mappings offline_name = apps.IdMappings.get(current_app) - offline_match = next( - (name for query, name in apps.NameMatching.items() if query in current_app), None - ) + offline_match = next((name for query, name in apps.NameMatching.items() if query in current_app), None) external_name = None external_icon = None @@ -985,7 +995,7 @@ async def _update_media_status(self): external_icon = metadata.get("icon") _LOG.debug("External app metadata: %s", filter_data_img_properties(metadata)) - app_name = (external_name or offline_name or offline_match or current_app) + app_name = external_name or offline_name or offline_match or current_app self._media_app = app_name self._app_image_url = external_icon or "" @@ -994,7 +1004,9 @@ async def _update_media_status(self): # --- Chromecast Active State Handling --- if chromecast_active: # Media Title from Chromecast (fallback to app_name) - self._media_title = chromecast_status.title if chromecast_status.title is not None else chromecast_status.artist or "" + self._media_title = ( + chromecast_status.title if chromecast_status.title is not None else chromecast_status.artist or "" + ) update[MediaAttr.MEDIA_TITLE] = self._media_title or chromecast_status.artist # Media Image from Chromecast @@ -1002,10 +1014,16 @@ async def _update_media_status(self): self._media_image_url = chromecast_status.images[0].url # External Artwork fallback (if enabled) - elif self._device_config.use_external_metadata and chromecast_status.player_state in ["PLAYING", "PAUSED", "BUFFERING"]: - self._media_image_url = await get_best_artwork( - chromecast_status.title, chromecast_status.artist, current_app - ) or external_icon or HOMESCREEN_IMAGE + elif self._device_config.use_external_metadata and chromecast_status.player_state in [ + "PLAYING", + "PAUSED", + "BUFFERING", + ]: + self._media_image_url = ( + await get_best_artwork(chromecast_status.title, chromecast_status.artist, current_app) + or external_icon + or HOMESCREEN_IMAGE + ) else: self._media_image_url = external_icon or HOMESCREEN_IMAGE @@ -1015,28 +1033,32 @@ async def _update_media_status(self): chromecast_status.player_state, media_player.States.PLAYING ) - update.update({ - MediaAttr.MEDIA_ARTIST: chromecast_status.artist or "", - MediaAttr.MEDIA_ALBUM: chromecast_status.album_name or "", - MediaAttr.MEDIA_POSITION: int(chromecast_status.current_time or 0), - MediaAttr.MEDIA_DURATION: int(chromecast_status.duration or 0), - MediaAttr.SOURCE: app_name, - }) + update.update( + { + MediaAttr.MEDIA_ARTIST: chromecast_status.artist or "", + MediaAttr.MEDIA_ALBUM: chromecast_status.album_name or "", + MediaAttr.MEDIA_POSITION: int(chromecast_status.current_time or 0), + MediaAttr.MEDIA_DURATION: int(chromecast_status.duration or 0), + MediaAttr.SOURCE: app_name, + } + ) # --- Chromecast Inactive or Disabled: App-level metadata only --- else: self._media_title = app_name self._media_image_url = self._app_image_url or HOMESCREEN_IMAGE - update.update({ - MediaAttr.MEDIA_TITLE: app_name, - MediaAttr.MEDIA_IMAGE_URL: await encode_icon_to_data_uri(self._media_image_url), - MediaAttr.STATE: media_player.States.PLAYING.value, - MediaAttr.SOURCE: app_name, - MediaAttr.MEDIA_ARTIST: "", - MediaAttr.MEDIA_ALBUM: "", - MediaAttr.MEDIA_POSITION: 0, - MediaAttr.MEDIA_DURATION: 0, - }) + update.update( + { + MediaAttr.MEDIA_TITLE: app_name, + MediaAttr.MEDIA_IMAGE_URL: await encode_icon_to_data_uri(self._media_image_url), + MediaAttr.STATE: media_player.States.PLAYING.value, + MediaAttr.SOURCE: app_name, + MediaAttr.MEDIA_ARTIST: "", + MediaAttr.MEDIA_ALBUM: "", + MediaAttr.MEDIA_POSITION: 0, + MediaAttr.MEDIA_DURATION: 0, + } + ) self.events.emit(Events.UPDATE, self._identifier, update)