Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
5aabe05
adding in first draft of external metadata + cache, updating tv.py to…
thomasm789 Apr 12, 2025
fdf1209
replacing UC_CONFIG_HOME with UC_DATA_HOME
thomasm789 Apr 12, 2025
1134247
test - updating setupflow and pass param through at device level
thomasm789 Apr 12, 2025
16c75bf
update - removing if condition allowing the the external metadata and…
thomasm789 Apr 12, 2025
35a4f0d
continued attempt to get use_external_metadata working - now have it …
thomasm789 Apr 12, 2025
7a68253
removing unused method
thomasm789 Apr 12, 2025
c9f61db
updating logic to conformto albinators chromecast repo
thomasm789 Apr 12, 2025
dda7fc1
syntax correction
thomasm789 Apr 12, 2025
5367f7e
syntax correction
thomasm789 Apr 12, 2025
de18f0a
syntax corrections and debugging
thomasm789 Apr 12, 2025
484ce8f
debugging
thomasm789 Apr 12, 2025
36951e5
adding in additional app reference
thomasm789 Apr 12, 2025
e7af5e0
debugging
thomasm789 Apr 12, 2025
8cbabaf
debugging
thomasm789 Apr 12, 2025
7e4cc21
updates
thomasm789 Apr 12, 2025
c38ed4e
adding support for new external metadata json object and also support…
thomasm789 Apr 12, 2025
6884138
adding icon media_image_url support
thomasm789 Apr 12, 2025
fdf5b6a
big one! added in update to support all existing apps with icons and …
thomasm789 Apr 12, 2025
34688c1
isort
thomasm789 Apr 12, 2025
088e48f
Update README.md
thomasm789 Apr 13, 2025
dfa672f
updating priority logic
thomasm789 Apr 12, 2025
5338f8b
Update setup_flow.py
thomasm789 Apr 14, 2025
5d583da
linting
thomasm789 Apr 12, 2025
fac19d6
Update requirements.txt
thomasm789 Apr 14, 2025
bb473b1
applying feedback from PR
thomasm789 Apr 14, 2025
37a09a7
reverting back to files
thomasm789 Apr 14, 2025
488dea7
updates
thomasm789 Apr 15, 2025
a5d29ff
Merge branch 'main' into external-metadata
thomasm789 Apr 15, 2025
03edc0b
bug fix
thomasm789 Apr 15, 2025
a5840f9
updating android home media title
thomasm789 Apr 15, 2025
f9974ac
clean up
thomasm789 Apr 15, 2025
61d412a
linting
thomasm789 Apr 15, 2025
6469c3f
updating logic to support http urls and file uris and covert to base6…
thomasm789 Apr 17, 2025
9ceb657
updating logic to support http urls and file uris and covert to base6…
thomasm789 Apr 17, 2025
db431fb
adding in Marcus' additions
thomasm789 Apr 17, 2025
e6e3d9b
updating encode to base64
thomasm789 Apr 17, 2025
1b4a166
updating encode to base64
thomasm789 Apr 17, 2025
a978171
updating encode to base64
thomasm789 Apr 17, 2025
64360c4
filter media_image_url property in log messages
zehnm Apr 17, 2025
5feddc0
file open issue
thomasm789 Apr 17, 2025
46ed72d
tweaks
thomasm789 Apr 17, 2025
dcdc7c5
setting some external defaults to show nice android tv logo
thomasm789 Apr 17, 2025
7900776
enable base64 img log filter
thomasm789 Apr 17, 2025
628a66a
updating metadata logic
thomasm789 Apr 17, 2025
3c8926a
updating text to setupflow
thomasm789 Apr 17, 2025
ccf272e
DRY
thomasm789 Apr 17, 2025
5f3b4a8
tweaks
thomasm789 Apr 17, 2025
9d28ed1
tweaks
thomasm789 Apr 17, 2025
df960c5
update
thomasm789 Apr 22, 2025
8d4f4c0
Merge branch 'main' into external-metadata
thomasm789 Apr 22, 2025
91dfadd
syntax fixes
thomasm789 Apr 22, 2025
d95ca33
Fix linting, remove unused variables
zehnm Apr 24, 2025
d4bda79
clean up
zehnm Apr 24, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Android TV integration for Remote Two/3

Using [androidtvremote2](https://github.com/tronikos/androidtvremote2), [uc-integration-api](https://github.com/aitatoi/integration-python-library) and [pychromecast](https://github.com/home-assistant-libs/pychromecast).
Using [androidtvremote2](https://github.com/tronikos/androidtvremote2), [uc-integration-api](https://github.com/aitatoi/integration-python-library), [pychromecast](https://github.com/home-assistant-libs/pychromecast) and [google-play-scraper](https://github.com/JoMingyu/google-play-scraper).

The integration currently supports almost all features that the androidtvremote2 library provides.
Button control and ON/OFF states are supported. With the optional Google Cast support, media playing information can be
Expand All @@ -16,6 +16,7 @@ Android TV devices. It can be run as an external integration for development
is exposed per Android TV device to the Remote.
- Device profiles allow device specific support and custom key bindings, for example double-click or long-press actions.
See [command mappings](docs/command_mapping.md) for more information.
- Optional external metadata lookup using the Google Play Store

## Standalone Usage
### Setup
Expand Down
10 changes: 10 additions & 0 deletions data/external_cache/app_metadata.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"com.google.android.tvlauncher": {
"name": "Android TV Home",
"icon": "data/external_cache/icons/androidtv.png"
},
"com.google.android.backdrop": {
"name": "Backdrop Daydream",
"icon": "data/external_cache/icons/androidtv.png"
}
}
Binary file added data/external_cache/icons/androidtv.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions intg-androidtv/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
"tv.wuaki": "Rakuten TV",
"homedia.sky.sport": "SKY",
"com.teamsmart.videomanager.tv": "SmartTube",
"com.nathnetwork.supersmart": "SuperSmart",
"nl.rtl.videoland.v2": "Videoland",
"com.disney.disneyplus": "Disney+",
"com.netflix.ninja": "Netflix",
Expand Down Expand Up @@ -87,6 +88,7 @@
# Used to show which app is currently in the foreground (currently playing)
NameMatching = {
"youtube": "YouTube",
"videomanager": "YouTube",
"amazonvideo": "Prime Video",
"apple": "Apple TV",
"plex": "Plex",
Expand Down
18 changes: 15 additions & 3 deletions intg-androidtv/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ class AtvDevice:
"""Device model name."""
auth_error: bool = False
"""Authentication error, device requires pairing."""
use_external_metadata: bool = False
"""Enable External Metadata."""
use_chromecast: bool = False
"""Enable Chromecast features."""

Expand Down Expand Up @@ -129,6 +131,7 @@ def update(self, atv: AtvDevice) -> bool:
item.manufacturer = atv.manufacturer
item.model = atv.model
item.auth_error = atv.auth_error
item.use_external_metadata = atv.use_external_metadata
item.use_chromecast = atv.use_chromecast
return self.store()
return False
Expand Down Expand Up @@ -184,7 +187,11 @@ def clear(self) -> None:
try:
os.remove(file)
except OSError as ex:
_LOG.error("Failed to remove certificate file %s: %s", os.path.basename(file), ex)
_LOG.error(
"Failed to remove certificate file %s: %s",
os.path.basename(file),
ex,
)

self._config = []

Expand Down Expand Up @@ -227,6 +234,7 @@ def load(self) -> bool:
item.get("manufacturer", ""),
item.get("model", ""),
item.get("auth_error", False),
item.get("use_external_metadata", False),
item.get("use_chromecast", False),
)
self._config.append(atv)
Expand Down Expand Up @@ -268,9 +276,9 @@ async def migrate(self) -> bool:
android_tv = AndroidTv(self.certfile(item.id), self.keyfile(item.id), item)
if await android_tv.init(10) and await android_tv.connect(10):
if device_info := android_tv.device_info:
item.manufacturer = android_tv.device_info
item.manufacturer = device_info.get("manufacturer", "")
item.model = device_info.get("model", "")
item.use_external_metadata = device_info.get("use_external_metadata", False)

_LOG.info(
"Updating device configuration '%s' (%s) with: manufacturer=%s, model=%s",
Expand Down Expand Up @@ -336,7 +344,11 @@ def assign_default_certs_to_device(self, atv_id: str, force: bool = False) -> bo
)
os.rename(old_keyfile, new_file)
except OSError as ex:
_LOG.error("Error while migrating certificate file %s: %s", os.path.basename(new_file), ex)
_LOG.error(
"Error while migrating certificate file %s: %s",
os.path.basename(new_file),
ex,
)
return False
return True

Expand Down
11 changes: 9 additions & 2 deletions intg-androidtv/discover.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@ async def android_tvs(timeout: int = 10) -> list[dict[str, str]]:
discovered_android_tvs: list[dict[str, str]] = []

def on_service_state_changed(
zeroconf: Zeroconf, service_type: str, name: str, state_change: ServiceStateChange
zeroconf: Zeroconf,
service_type: str,
name: str,
state_change: ServiceStateChange,
) -> None:
if state_change is not ServiceStateChange.Added:
return
Expand All @@ -44,7 +47,11 @@ async def display_service_info(zeroconf: Zeroconf, service_type: str, name: str)

addresses = info.parsed_scoped_addresses()
if addresses:
discovered_tv = {"name": name_final, "label": f"{name_final} [{addresses[0]}]", "address": addresses[0]}
discovered_tv = {
"name": name_final,
"label": f"{name_final} [{addresses[0]}]",
"address": addresses[0],
}
discovered_android_tvs.append(discovered_tv)
else:
_LOG.debug("No info for %s", name)
Expand Down
8 changes: 7 additions & 1 deletion intg-androidtv/driver.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import logging
import os
import sys
from copy import copy
from datetime import UTC, datetime
from typing import Any

Expand Down Expand Up @@ -232,7 +233,11 @@ async def handle_android_tv_update(atv_id: str, update: dict[str, Any]) -> None:

if _LOG.isEnabledFor(logging.DEBUG):
device = config.devices.get(atv_id)
_LOG.debug("[%s] device update: %s", device.name if device else atv_id, update)
# filter media_image_url property
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)

old_state = (
configured_entity.attributes[MediaAttr.STATE]
Expand Down Expand Up @@ -407,6 +412,7 @@ async def main():
logging.getLogger("profiles").setLevel(level)
logging.getLogger("setup_flow").setLevel(level)
logging.getLogger("androidtvremote2").setLevel(level)
logging.getLogger("external_metadata").setLevel(level)
# logging.getLogger("pychromecast").setLevel(level)

profile_path = os.path.join(api.config_dir_path, "profiles")
Expand Down
182 changes: 182 additions & 0 deletions intg-androidtv/external_metadata.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
"""
External metadata retrieval from Google Play.

:copyright: (c) 2025 by thomasm789, www.tmason.uk
:license: MPL-2.0, see LICENSE for more details.
"""

import base64
import json
import logging
import os
from io import BytesIO
from pathlib import Path
from typing import Dict
from urllib.parse import urlparse

import google_play_scraper
import requests
from PIL import Image
Comment thread
thomasm789 marked this conversation as resolved.
from PIL.Image import Resampling
from pychromecast.controllers.media import MediaImage

_LOG = logging.getLogger(__name__)

CACHE_ROOT = "external_cache"
ICON_SUBDIR = "icons"
ICON_SIZE = (120, 120)


# Paths
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)
return cache_root


def _get_metadata_dir() -> Path:
metadata_dir = _get_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.mkdir(parents=True, exist_ok=True)
return icon_dir


def _get_metadata_file_path() -> Path:
return _get_metadata_dir() / "app_metadata.json"


def _get_icon_path(package_id: str) -> Path:
return _get_icon_dir() / f"{package_id}.png"


# Cache Management
def _load_cache() -> Dict[str, Dict[str, str]]:
path = _get_metadata_file_path()
if path.exists():
try:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
return {}
return {}


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)


# Metadata Fetch
def _download_and_resize_icon(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)

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

try:
if _is_url(icon_path):
response = requests.get(icon_path, timeout=10)
response.raise_for_status()
img = Image.open(BytesIO(response.content))
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 = 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}"

except Exception as e:
_LOG.warning("Failed to encode icon to base64 for %s: %s", icon_path, e)
return ""


def _is_url(path: str) -> bool:
parsed = urlparse(path)
return parsed.scheme in ("http", "https")


def _fetch_google_play_metadata(package_id: str) -> Dict[str, str] | None:
try:
app = google_play_scraper.app(package_id)

name = app["title"]
icon_url = app["icon"]
icon_path = _download_and_resize_icon(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]
"""
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)

if metadata:
cache[package_id] = metadata
_save_cache(cache)
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": ""}
Loading