Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -105,3 +105,4 @@ config.json
*.pem
licenses.json
tools/licenses.md
/data/external_cache/icons/
10 changes: 7 additions & 3 deletions data/external_cache/app_metadata.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
119 changes: 76 additions & 43 deletions intg-androidtv/external_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
:license: MPL-2.0, see LICENSE for more details.
"""

import asyncio
import base64
import json
import logging
Expand All @@ -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
Expand Down Expand Up @@ -61,67 +62,97 @@ 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 {}


def _save_cache(cache: Dict[str, Dict[str, str]]) -> None:
path = _get_metadata_file_path()
with open(path, "w", encoding="utf-8") as f:
json.dump(cache, f, indent=2)
_LOG.debug("Saved metadata cache with %d entries", len(cache))


# Metadata Fetch
def _download_and_resize_icon(url: str, package_id: str) -> str | None:
async def _download_and_resize_icon(url: str, package_id: str) -> str | None:
_LOG.debug("Downloading and resizing icon for %s from %s", package_id, url)
try:
response = requests.get(url, timeout=10)
response.raise_for_status()
async with httpx.AsyncClient() as client:
response = await client.get(url, timeout=10)
response.raise_for_status()

img_bytes = BytesIO(response.content)

def resize_image() -> str:
img = Image.open(img_bytes)
img = img.resize(ICON_SIZE, Resampling.LANCZOS)
icon_path = _get_icon_path(package_id)
img.save(icon_path, format="PNG")
_LOG.debug("Saved resized icon to %s", icon_path)
return icon_path.name

img = Image.open(BytesIO(response.content))
img = img.resize(ICON_SIZE, Resampling.LANCZOS)
filename = await asyncio.to_thread(resize_image)
return filename

icon_path = _get_icon_path(package_id)
img.save(icon_path, format="PNG")
return str(icon_path)
except Exception as e:
_LOG.warning("Failed to fetch icon for %s: %s", package_id, e)
return None


def encode_icon_to_data_uri(icon_path: str) -> str:
async def encode_icon_to_data_uri(icon_name: str) -> str:
"""
Encode an image from a local file path or remote URL.

Returns a base64-encoded PNG data URI.
"""
if isinstance(icon_path, MediaImage):
icon_path = icon_path.url
_LOG.debug("Encoding icon to data URI: %s", icon_name)
if isinstance(icon_name, MediaImage):
icon_name = icon_name.url

# Already a base64 data URI
if isinstance(icon_path, str) and icon_path.startswith("data:image"):
return icon_path
if isinstance(icon_name, str) and icon_name.startswith("data:image"):
_LOG.debug("Icon is already a data URI")
return icon_name

try:
if _is_url(icon_path):
response = requests.get(icon_path, timeout=10)
response.raise_for_status()
img = Image.open(BytesIO(response.content))
else:
if _is_url(icon_name):
async with httpx.AsyncClient() as client:
response = await client.get(icon_name, timeout=10)
response.raise_for_status()
img_bytes = BytesIO(response.content)

def encode_image() -> str:
img = Image.open(img_bytes)
img = img.convert("RGBA")
buffer = BytesIO()
img.save(buffer, format="PNG")
encoded = base64.b64encode(buffer.getvalue()).decode("utf-8")
return f"data:image/png;base64,{encoded}"

return await asyncio.to_thread(encode_image)

def load_and_encode() -> str:
icon_path = _get_icon_dir() / icon_name
if not icon_path.exists():
raise FileNotFoundError(f"Icon not found: {icon_path}")
with open(icon_path, "rb") as f:
img = Image.open(f)
img.load() # Ensure the image is fully loaded before the file is closed

img = img.convert("RGBA")
img.load()
img = img.convert("RGBA")
buffer = BytesIO()
img.save(buffer, format="PNG")
encoded = base64.b64encode(buffer.getvalue()).decode("utf-8")
return f"data:image/png;base64,{encoded}"

buffer = BytesIO()
img.save(buffer, format="PNG")
encoded = base64.b64encode(buffer.getvalue()).decode("utf-8")
return f"data:image/png;base64,{encoded}"
return await asyncio.to_thread(load_and_encode)

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


Expand All @@ -130,22 +161,24 @@ def _is_url(path: str) -> bool:
return parsed.scheme in ("http", "https")


def _fetch_google_play_metadata(package_id: str) -> Dict[str, str] | None:
async def _fetch_google_play_metadata(package_id: str) -> Dict[str, str] | None:
_LOG.debug("Fetching metadata for %s from Google Play", package_id)
try:
app = google_play_scraper.app(package_id)
app = await asyncio.to_thread(google_play_scraper.app, package_id)

name = app["title"]
icon_url = app["icon"]
icon_path = _download_and_resize_icon(icon_url, package_id)
icon_name = await _download_and_resize_icon(icon_url, package_id)

return {"name": name, "icon": icon_path or ""}
_LOG.debug("Fetched metadata for %s: name='%s', icon='%s'", package_id, name, icon_name)
return {"name": name, "icon": icon_name or ""}

except Exception as e:
_LOG.warning("Google Play metadata fetch failed for %s: %s", package_id, e)
return None


def get_app_metadata(package_id: str) -> Dict[str, str]:
async def get_app_metadata(package_id: str) -> Dict[str, str]:
"""
Fetch metadata for a mobile application specified by the package ID.

Expand All @@ -161,22 +194,22 @@ def get_app_metadata(package_id: str) -> Dict[str, str]:
it returns the package ID as the name and an empty string as the icon.
:rtype: Dict[str, str]
"""
_LOG.debug("Getting app metadata for %s", package_id)
cache = _load_cache()
if package_id in cache:
icon_path = cache[package_id].get("icon")
icon_data_uri = encode_icon_to_data_uri(icon_path) if icon_path else ""
_LOG.debug("Cache hit for %s", package_id)
icon_name = cache[package_id].get("icon")
icon_data_uri = await encode_icon_to_data_uri(icon_name) if icon_name else ""
return {"name": cache[package_id]["name"], "icon": icon_data_uri}

# Try Google Play
metadata = _fetch_google_play_metadata(package_id)
# if not metadata:
# Additional Fallback option for the future maybe APKPure or another source
# metadata = fetch_fallback_metadata(package_id)
_LOG.debug("Cache miss for %s", package_id)
metadata = await _fetch_google_play_metadata(package_id)

if metadata:
cache[package_id] = metadata
_save_cache(cache)
icon_data_uri = encode_icon_to_data_uri(metadata["icon"]) if metadata["icon"] else ""
icon_data_uri = await encode_icon_to_data_uri(metadata["icon"]) if metadata["icon"] else ""
return {"name": metadata["name"], "icon": icon_data_uri}

_LOG.debug("Falling back to default metadata for %s", package_id)
return {"name": package_id, "icon": ""}
Loading