New apps Added
This commit is contained in:
@@ -0,0 +1,431 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from .const import DOMAIN, SERVICE_NEXT_SLIDE, SERVICE_REFRESH_ALBUM, ATTR_ENTRY_ID
|
||||
from .store import SlideshowStore
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS: list[str] = ["camera", "sensor", "button", "number", "select", "text", "switch"]
|
||||
|
||||
CARD_STATIC_PATH = "/album_slideshow_static"
|
||||
CARD_FILE = "album-slideshow-card.js"
|
||||
|
||||
|
||||
async def _async_register_card(hass: HomeAssistant) -> None:
|
||||
"""Serve the Lovelace card JS and register it as a frontend module.
|
||||
|
||||
Idempotent: only the first config entry to load triggers registration
|
||||
for the HA session. The card lets dashboards cross-fade between slides
|
||||
in the browser (GPU compositor) instead of forcing the camera entity
|
||||
to render a JPEG burst on the event loop.
|
||||
"""
|
||||
if hass.data.get(DOMAIN, {}).get("card_registered"):
|
||||
return
|
||||
|
||||
integration_dir = os.path.dirname(__file__)
|
||||
www_dir = os.path.join(integration_dir, "www")
|
||||
card_path = os.path.join(www_dir, CARD_FILE)
|
||||
|
||||
if not os.path.isfile(card_path):
|
||||
# Some HACS upgrade paths (and broken zip extractors) drop the
|
||||
# ``www/`` subdirectory. Try to recover by checking whether the
|
||||
# integration root has the file under a literal-backslash name
|
||||
# (a symptom of zips written with Windows path separators) or
|
||||
# directly at the root, and salvage it into ``www/`` so the
|
||||
# rest of the registration can proceed.
|
||||
recovered = await hass.async_add_executor_job(
|
||||
_recover_card_from_root, integration_dir, www_dir, card_path
|
||||
)
|
||||
if not recovered:
|
||||
_LOGGER.warning(
|
||||
"Album Slideshow card missing on disk (%s). Re-install"
|
||||
" the integration via HACS (3-dot menu -> Redownload)"
|
||||
" or copy %s/%s into the album_slideshow folder."
|
||||
" The custom:album-slideshow-card type will not be"
|
||||
" available until this is fixed.",
|
||||
card_path,
|
||||
"www",
|
||||
CARD_FILE,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
from homeassistant.components.http import StaticPathConfig
|
||||
|
||||
await hass.http.async_register_static_paths(
|
||||
[StaticPathConfig(CARD_STATIC_PATH, www_dir, False)]
|
||||
)
|
||||
except Exception: # noqa: BLE001 - many possible failure modes here
|
||||
_LOGGER.exception(
|
||||
"Failed to register static path for Album Slideshow card"
|
||||
)
|
||||
return
|
||||
|
||||
# Cache-bust the card URL with the integration version so dashboards
|
||||
# always pick up the script that matches the running integration
|
||||
# rather than a stale copy from a previous release.
|
||||
version = await hass.async_add_executor_job(
|
||||
_read_manifest_version, integration_dir
|
||||
)
|
||||
card_url = f"{CARD_STATIC_PATH}/{CARD_FILE}"
|
||||
if version:
|
||||
card_url = f"{card_url}?v={version}"
|
||||
|
||||
# Prefer registering the card as a Lovelace resource for storage-mode
|
||||
# dashboards. Resources are loaded as part of the Lovelace bootstrap,
|
||||
# before any dashboard renders custom cards, which removes the race
|
||||
# where the dashboard can hit "Custom element doesn't exist" if the
|
||||
# browser hasn't finished loading the module yet (a real risk after
|
||||
# HA restarts and integration upgrades that bust the cache).
|
||||
#
|
||||
# The storage-mode resources collection is only consumed by
|
||||
# storage-mode dashboards; YAML-mode dashboards (whether the user
|
||||
# has ``lovelace.mode: yaml`` globally or per-dashboard
|
||||
# ``mode: yaml`` entries) read only the resources declared in their
|
||||
# own YAML and would otherwise never load the card. That's why we
|
||||
# *always* also call ``add_extra_js_url``: the frontend injects a
|
||||
# ``<script>`` tag on every Lovelace render regardless of mode, so
|
||||
# it's the universal fallback. Module loads are deduplicated by URL
|
||||
# and the card's ``customElements.define()`` calls have an
|
||||
# idempotency guard, so a storage-mode dashboard picking up both
|
||||
# paths is a harmless no-op.
|
||||
resource_registered = await _try_register_lovelace_resource(hass, card_url)
|
||||
if resource_registered:
|
||||
hass.data.setdefault(DOMAIN, {})[
|
||||
"lovelace_resource_registered"
|
||||
] = True
|
||||
|
||||
try:
|
||||
from homeassistant.components.frontend import add_extra_js_url
|
||||
|
||||
add_extra_js_url(hass, card_url)
|
||||
except Exception: # noqa: BLE001
|
||||
_LOGGER.exception(
|
||||
"Failed to add Album Slideshow card via add_extra_js_url"
|
||||
" (URL %s); the card may still load if a Lovelace resource"
|
||||
" was registered above.",
|
||||
card_url,
|
||||
)
|
||||
|
||||
hass.data.setdefault(DOMAIN, {})["card_registered"] = True
|
||||
if resource_registered:
|
||||
_LOGGER.info(
|
||||
"Album Slideshow card registered (Lovelace resource +"
|
||||
" add_extra_js_url) at %s",
|
||||
card_url,
|
||||
)
|
||||
else:
|
||||
_LOGGER.info(
|
||||
"Album Slideshow card registered via add_extra_js_url at"
|
||||
" %s; Lovelace resources collection was not available so"
|
||||
" no resource entry was created.",
|
||||
card_url,
|
||||
)
|
||||
|
||||
|
||||
async def _try_register_lovelace_resource(
|
||||
hass: HomeAssistant, card_url: str
|
||||
) -> bool:
|
||||
"""Register the card as a Lovelace resource for storage-mode dashboards.
|
||||
|
||||
Returns ``True`` if the resource was added (or already present at the
|
||||
requested version); ``False`` if Lovelace isn't loaded, the dashboard
|
||||
is in YAML mode, or anything else went wrong - in which case the
|
||||
caller is expected to fall back to ``add_extra_js_url``.
|
||||
|
||||
The resource collection API is internal to HA and has changed shape
|
||||
between versions, so we feel our way through it with ``getattr`` and
|
||||
swallow any unexpected exception. The downside of getting this wrong
|
||||
is one extra script tag in the dashboard, not a crash.
|
||||
"""
|
||||
try:
|
||||
lovelace_data = hass.data.get("lovelace")
|
||||
if lovelace_data is None:
|
||||
_LOGGER.debug(
|
||||
"Lovelace data not yet present; cannot register resource"
|
||||
)
|
||||
return False
|
||||
|
||||
# In recent HA the lovelace key is a LovelaceData object exposing
|
||||
# ``resources``; in older versions it was a dict with the same key.
|
||||
if isinstance(lovelace_data, dict):
|
||||
resources = lovelace_data.get("resources")
|
||||
else:
|
||||
resources = getattr(lovelace_data, "resources", None)
|
||||
|
||||
if resources is None or not hasattr(resources, "async_create_item"):
|
||||
# YAML-mode dashboards expose a ResourceYAMLCollection that is
|
||||
# read-only; users edit ``configuration.yaml`` themselves.
|
||||
_LOGGER.debug(
|
||||
"Lovelace resources unavailable or read-only;"
|
||||
" falling back to add_extra_js_url"
|
||||
)
|
||||
return False
|
||||
|
||||
# Make sure the storage collection has loaded its file. Some HA
|
||||
# versions lazy-load on first ``async_items()`` access; calling
|
||||
# ``async_load`` explicitly is safe either way.
|
||||
if hasattr(resources, "async_load"):
|
||||
try:
|
||||
await resources.async_load()
|
||||
except Exception: # noqa: BLE001
|
||||
# Storage collection may be in an unloaded state with no
|
||||
# file yet - treated the same as "no items".
|
||||
pass
|
||||
|
||||
items = []
|
||||
if hasattr(resources, "async_items"):
|
||||
try:
|
||||
items = list(resources.async_items())
|
||||
except Exception: # noqa: BLE001
|
||||
items = []
|
||||
|
||||
base = card_url.split("?", 1)[0]
|
||||
same_version_present = False
|
||||
stale_ids: list[str] = []
|
||||
for item in items:
|
||||
if isinstance(item, dict):
|
||||
url = item.get("url", "") or ""
|
||||
item_id = item.get("id")
|
||||
else:
|
||||
url = getattr(item, "url", "") or ""
|
||||
item_id = getattr(item, "id", None)
|
||||
if url.split("?", 1)[0] != base:
|
||||
continue
|
||||
if url == card_url:
|
||||
same_version_present = True
|
||||
elif item_id:
|
||||
stale_ids.append(item_id)
|
||||
|
||||
# Strip stale registrations (older versions of the card pinned via
|
||||
# an out-of-date ``?v=...`` query) so the dashboard doesn't pull
|
||||
# both the new and the old script.
|
||||
for item_id in stale_ids:
|
||||
try:
|
||||
await resources.async_delete_item(item_id)
|
||||
except Exception: # noqa: BLE001
|
||||
_LOGGER.debug(
|
||||
"Could not delete stale Lovelace resource id=%s",
|
||||
item_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if not same_version_present:
|
||||
await resources.async_create_item(
|
||||
{"url": card_url, "res_type": "module"}
|
||||
)
|
||||
return True
|
||||
except Exception: # noqa: BLE001
|
||||
_LOGGER.debug(
|
||||
"Lovelace resource registration failed; will fall back to"
|
||||
" add_extra_js_url",
|
||||
exc_info=True,
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def _read_manifest_version(integration_dir: str) -> str | None:
|
||||
try:
|
||||
with open(
|
||||
os.path.join(integration_dir, "manifest.json"),
|
||||
"r",
|
||||
encoding="utf-8",
|
||||
) as fh:
|
||||
return json.load(fh).get("version")
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
|
||||
|
||||
def _recover_card_from_root(
|
||||
integration_dir: str, www_dir: str, card_path: str
|
||||
) -> bool:
|
||||
"""Salvage the card file from a broken extraction.
|
||||
|
||||
PowerShell's ``Compress-Archive`` writes zip entries with backslash
|
||||
separators, which Linux unzip implementations may treat as literal
|
||||
filenames. The resulting layout is::
|
||||
|
||||
custom_components/album_slideshow/www\\album-slideshow-card.js
|
||||
|
||||
instead of the expected ``www/album-slideshow-card.js``. Move it to
|
||||
the right place so subsequent installs don't need a re-download.
|
||||
"""
|
||||
candidates = [
|
||||
os.path.join(integration_dir, f"www\\{CARD_FILE}"),
|
||||
os.path.join(integration_dir, CARD_FILE),
|
||||
]
|
||||
for src in candidates:
|
||||
if os.path.isfile(src):
|
||||
try:
|
||||
os.makedirs(www_dir, exist_ok=True)
|
||||
os.replace(src, card_path)
|
||||
_LOGGER.info(
|
||||
"Recovered Album Slideshow card from %s", src
|
||||
)
|
||||
return True
|
||||
except OSError:
|
||||
_LOGGER.exception(
|
||||
"Found candidate card at %s but could not move it"
|
||||
" to %s",
|
||||
src,
|
||||
card_path,
|
||||
)
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
async def _async_cleanup_legacy_entities(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
registry = er.async_get(hass)
|
||||
# Server-side transitions were tried in earlier 0.7-rc builds and
|
||||
# removed before the first public 0.7 pre-release because the
|
||||
# resource cost on low-end hardware was too high. Drop any leftover
|
||||
# transition entities so users don't see stale disabled rows under
|
||||
# the device.
|
||||
legacy_unique_ids = {
|
||||
f"{entry.entry_id}_max_items",
|
||||
f"{entry.entry_id}_transition",
|
||||
f"{entry.entry_id}_transition_duration_ms",
|
||||
f"{entry.entry_id}_transition_fps",
|
||||
}
|
||||
|
||||
for entity in er.async_entries_for_config_entry(registry, entry.entry_id):
|
||||
if entity.unique_id in legacy_unique_ids:
|
||||
registry.async_remove(entity.entity_id)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: dict) -> bool:
|
||||
"""Register the Lovelace card during HA bootstrap.
|
||||
|
||||
Running this in ``async_setup`` (not only in ``async_setup_entry``)
|
||||
registers the card's static path and frontend resource at
|
||||
integration load time, well before any config entry finishes
|
||||
setting up. The earlier the resource is registered, the smaller
|
||||
the window in which a dashboard can render a card before the
|
||||
script's ``customElements.define()`` has run, which is what
|
||||
produces the intermittent "Custom element doesn't exist:
|
||||
album-slideshow-card" errors on slow devices (tablets) that open
|
||||
the dashboard during HA startup.
|
||||
|
||||
We also retry the Lovelace storage-resource registration once HA
|
||||
finishes starting, because ``hass.data['lovelace']`` may not yet
|
||||
be populated when ``async_setup`` runs. The storage-resource path
|
||||
is the only mechanism that *gates* dashboard render on resource
|
||||
load (``add_extra_js_url`` just injects a script tag with no
|
||||
ordering guarantee), so we want it to succeed even if we beat
|
||||
Lovelace to the punch on the first attempt.
|
||||
"""
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
await _async_register_card(hass)
|
||||
|
||||
async def _retry_lovelace_resource(_event) -> None:
|
||||
if hass.data.get(DOMAIN, {}).get("lovelace_resource_registered"):
|
||||
return
|
||||
integration_dir = os.path.dirname(__file__)
|
||||
version = await hass.async_add_executor_job(
|
||||
_read_manifest_version, integration_dir
|
||||
)
|
||||
card_url = f"{CARD_STATIC_PATH}/{CARD_FILE}"
|
||||
if version:
|
||||
card_url = f"{card_url}?v={version}"
|
||||
if await _try_register_lovelace_resource(hass, card_url):
|
||||
hass.data.setdefault(DOMAIN, {})[
|
||||
"lovelace_resource_registered"
|
||||
] = True
|
||||
_LOGGER.info(
|
||||
"Album Slideshow card registered as Lovelace resource"
|
||||
" on HA started (late retry; Lovelace was not yet"
|
||||
" ready during integration setup)"
|
||||
)
|
||||
|
||||
hass.bus.async_listen_once(
|
||||
EVENT_HOMEASSISTANT_STARTED, _retry_lovelace_resource
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
from .coordinator import AlbumCoordinator
|
||||
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
# Domain-wide concurrency limit on compose work. Multiple album cameras
|
||||
# share HA's small executor pool; without coordination they can all
|
||||
# decode + render in parallel and saturate the loop. One ticket means
|
||||
# at most one album does PIL work at a time, queueing the rest.
|
||||
if "compose_semaphore" not in hass.data[DOMAIN]:
|
||||
hass.data[DOMAIN]["compose_semaphore"] = asyncio.Semaphore(1)
|
||||
|
||||
# Register the Lovelace card once per HA session. The card runs the
|
||||
# GPU-composited transitions in the browser so the camera entity
|
||||
# itself never has to render a transition burst on the event loop.
|
||||
await _async_register_card(hass)
|
||||
|
||||
await _async_cleanup_legacy_entities(hass, entry)
|
||||
|
||||
store = SlideshowStore()
|
||||
coordinator = AlbumCoordinator(hass, entry, store)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
hass.data[DOMAIN][entry.entry_id] = {
|
||||
"coordinator": coordinator,
|
||||
"store": store,
|
||||
"camera": None,
|
||||
}
|
||||
|
||||
async def _next_slide(call) -> None:
|
||||
entry_id = call.data.get(ATTR_ENTRY_ID)
|
||||
if not entry_id:
|
||||
return
|
||||
data = hass.data.get(DOMAIN, {}).get(entry_id)
|
||||
if not data:
|
||||
return
|
||||
cam = data.get("camera")
|
||||
if cam:
|
||||
await cam.async_force_next()
|
||||
|
||||
async def _refresh_album(call) -> None:
|
||||
entry_id = call.data.get(ATTR_ENTRY_ID)
|
||||
if not entry_id:
|
||||
return
|
||||
data = hass.data.get(DOMAIN, {}).get(entry_id)
|
||||
if not data:
|
||||
return
|
||||
cam = data.get("camera")
|
||||
if cam:
|
||||
await cam.async_force_refresh()
|
||||
|
||||
if not hass.services.has_service(DOMAIN, SERVICE_NEXT_SLIDE):
|
||||
hass.services.async_register(DOMAIN, SERVICE_NEXT_SLIDE, _next_slide)
|
||||
|
||||
if not hass.services.has_service(DOMAIN, SERVICE_REFRESH_ALBUM):
|
||||
hass.services.async_register(DOMAIN, SERVICE_REFRESH_ALBUM, _refresh_album)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
store.notify()
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
if unload_ok:
|
||||
domain_data = hass.data.get(DOMAIN, {})
|
||||
domain_data.pop(entry.entry_id, None)
|
||||
# Drop the shared semaphore once the last album is gone so it's
|
||||
# re-created if the integration is re-added later.
|
||||
entry_keys = [
|
||||
k for k in domain_data.keys() if k != "compose_semaphore"
|
||||
]
|
||||
if not entry_keys:
|
||||
domain_data.pop("compose_semaphore", None)
|
||||
return unload_ok
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
@@ -0,0 +1,68 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.button import ButtonEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .const import DOMAIN, SERVICE_NEXT_SLIDE, SERVICE_REFRESH_ALBUM, ATTR_ENTRY_ID
|
||||
from .coordinator import AlbumCoordinator
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback) -> None:
|
||||
coordinator: AlbumCoordinator = hass.data[DOMAIN][entry.entry_id]["coordinator"]
|
||||
async_add_entities(
|
||||
[
|
||||
NextSlideButton(hass, entry, coordinator),
|
||||
RefreshAlbumButton(hass, entry, coordinator),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class _BaseButton(ButtonEntity):
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry, coordinator: AlbumCoordinator) -> None:
|
||||
self.hass = hass
|
||||
self.entry = entry
|
||||
self.coordinator = coordinator
|
||||
|
||||
@property
|
||||
def device_info(self):
|
||||
return {
|
||||
"identifiers": {(DOMAIN, self.entry.entry_id)},
|
||||
"name": f"Album Slideshow {self.entry.title}",
|
||||
"manufacturer": "Album Slideshow",
|
||||
}
|
||||
|
||||
|
||||
class NextSlideButton(_BaseButton):
|
||||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry, coordinator: AlbumCoordinator) -> None:
|
||||
super().__init__(hass, entry, coordinator)
|
||||
self._attr_unique_id = f"{entry.entry_id}_next_button"
|
||||
self._attr_name = "Next slide"
|
||||
self._attr_icon = "mdi:skip-next"
|
||||
|
||||
async def async_press(self) -> None:
|
||||
await self.hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_NEXT_SLIDE,
|
||||
{ATTR_ENTRY_ID: self.entry.entry_id},
|
||||
blocking=False,
|
||||
)
|
||||
|
||||
|
||||
class RefreshAlbumButton(_BaseButton):
|
||||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry, coordinator: AlbumCoordinator) -> None:
|
||||
super().__init__(hass, entry, coordinator)
|
||||
self._attr_unique_id = f"{entry.entry_id}_refresh_button"
|
||||
self._attr_name = "Refresh album"
|
||||
self._attr_icon = "mdi:refresh"
|
||||
|
||||
async def async_press(self) -> None:
|
||||
await self.hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_REFRESH_ALBUM,
|
||||
{ATTR_ENTRY_ID: self.entry.entry_id},
|
||||
blocking=False,
|
||||
)
|
||||
@@ -0,0 +1,922 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections import OrderedDict
|
||||
import logging
|
||||
import random
|
||||
from pathlib import Path
|
||||
|
||||
import async_timeout
|
||||
from PIL import Image
|
||||
|
||||
from homeassistant.components.camera import Camera
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
MAX_RESOLUTION_SHORT_EDGE,
|
||||
ORIENTATION_MISMATCH_PAIR,
|
||||
ORIENTATION_MISMATCH_AVOID,
|
||||
ORDER_ALBUM,
|
||||
ORDER_RANDOM,
|
||||
PROVIDER_GOOGLE_SHARED,
|
||||
)
|
||||
from . import image_processing as ip
|
||||
from . import playlist
|
||||
from .coordinator import AlbumCoordinator, MediaItem
|
||||
from .store import SlideshowStore
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Cap a single download at 64 MB. Larger images are rejected before decode
|
||||
# to protect low-memory devices. This is well above any realistic camera
|
||||
# JPEG; RAW/NEF/etc. aren't supported as camera frames anyway.
|
||||
_MAX_DOWNLOAD_BYTES = 64 * 1024 * 1024
|
||||
|
||||
# Only these content types are accepted as image bodies. If a server returns
|
||||
# HTML (captive portal, 404 page rendered as 200, etc.) we reject it early.
|
||||
_ACCEPTED_IMAGE_PREFIX = ("image/",)
|
||||
|
||||
# Max candidates we'll scan when searching for a mismatched-orientation
|
||||
# pairing partner. Metadata-only checks are nearly free; decode-only checks
|
||||
# (no metadata available) are expensive.
|
||||
_PAIR_SEARCH_LIMIT = 12
|
||||
_SKIP_SEARCH_LIMIT = 30
|
||||
|
||||
|
||||
def _ts_to_iso(ts_ms: int | None) -> str | None:
|
||||
"""Convert epoch milliseconds to an ISO-8601 string in UTC, or None."""
|
||||
if not isinstance(ts_ms, int):
|
||||
return None
|
||||
from datetime import datetime, timezone
|
||||
try:
|
||||
return datetime.fromtimestamp(ts_ms / 1000, tz=timezone.utc).isoformat()
|
||||
except (OverflowError, OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
class _DownloadCache:
|
||||
"""Byte-budget LRU cache for downloaded image data, O(1) per operation."""
|
||||
|
||||
def __init__(self, max_bytes: int) -> None:
|
||||
self._cache: "OrderedDict[str, bytes]" = OrderedDict()
|
||||
self._total_bytes: int = 0
|
||||
self._max_bytes: int = max(max_bytes, 1)
|
||||
|
||||
@property
|
||||
def total_bytes(self) -> int:
|
||||
return self._total_bytes
|
||||
|
||||
def get(self, url: str) -> bytes | None:
|
||||
data = self._cache.get(url)
|
||||
if data is None:
|
||||
return None
|
||||
self._cache.move_to_end(url)
|
||||
return data
|
||||
|
||||
def put(self, url: str, data: bytes) -> None:
|
||||
if len(data) > self._max_bytes:
|
||||
# Item exceeds the entire cache budget; skip caching but don't raise.
|
||||
return
|
||||
if url in self._cache:
|
||||
self._total_bytes -= len(self._cache[url])
|
||||
del self._cache[url]
|
||||
self._cache[url] = data
|
||||
self._total_bytes += len(data)
|
||||
self._evict()
|
||||
|
||||
def resize(self, max_bytes: int) -> None:
|
||||
self._max_bytes = max(max_bytes, 1)
|
||||
self._evict()
|
||||
|
||||
def _evict(self) -> None:
|
||||
while self._total_bytes > self._max_bytes and self._cache:
|
||||
_, data = self._cache.popitem(last=False)
|
||||
self._total_bytes -= len(data)
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback) -> None:
|
||||
coordinator: AlbumCoordinator = hass.data[DOMAIN][entry.entry_id]["coordinator"]
|
||||
store: SlideshowStore = hass.data[DOMAIN][entry.entry_id]["store"]
|
||||
|
||||
cam = AlbumSlideshowCamera(hass, entry, coordinator, store)
|
||||
hass.data[DOMAIN][entry.entry_id]["camera"] = cam
|
||||
|
||||
async_add_entities([cam])
|
||||
|
||||
|
||||
class AlbumSlideshowCamera(Camera):
|
||||
_attr_should_poll = False
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry, coordinator: AlbumCoordinator, store: SlideshowStore) -> None:
|
||||
super().__init__()
|
||||
self.hass = hass
|
||||
self.entry = entry
|
||||
self.coordinator = coordinator
|
||||
self.store = store
|
||||
|
||||
self._attr_name = f"Album Slideshow {entry.title}"
|
||||
self._attr_unique_id = f"{entry.entry_id}_camera"
|
||||
|
||||
self._rng = random.Random()
|
||||
self._index = 0
|
||||
self._random_order: list[int] = []
|
||||
self._random_pos = 0
|
||||
|
||||
self._download_cache = _DownloadCache(
|
||||
max_bytes=store.image_cache_mb * 1024 * 1024
|
||||
)
|
||||
self._recent_urls: list[str] = []
|
||||
self._last_is_portrait: bool | None = None
|
||||
# When the current frame is a paired image, this is [taken_a, taken_b]
|
||||
# ISO strings (top/left first); None for single frames.
|
||||
self._last_captured_at_pair: list[str | None] | None = None
|
||||
# Full per-half caption metadata for a paired frame: a list of two
|
||||
# dicts (top/left first) each carrying captured_at / location /
|
||||
# latitude / longitude. None for single frames. Lets the Lovelace
|
||||
# card overlay an accurate caption on each half of a pair.
|
||||
self._last_pair_frames: list[dict] | None = None
|
||||
# ``horizontal`` (side-by-side, left/right) or ``vertical`` (stacked,
|
||||
# top/bottom) for a paired frame; None for single frames.
|
||||
self._last_pair_orientation: str | None = None
|
||||
# Cached effective playlist (after date filter + ordering). Invalidated
|
||||
# by any store change or coordinator update.
|
||||
self._effective_cache: tuple[int, list[MediaItem]] | None = None
|
||||
|
||||
self._framebuffer: bytes | None = None
|
||||
|
||||
# MJPEG subscribers. Each open stream owns an asyncio.Queue of JPEG
|
||||
# byte payloads. The render loop pushes the latest still as soon
|
||||
# as it's encoded; if a subscriber falls behind we drop frames
|
||||
# for that subscriber rather than block the whole loop.
|
||||
self._mjpeg_subscribers: set[asyncio.Queue[bytes]] = set()
|
||||
|
||||
# Monotonic counter incremented every time a new still is committed.
|
||||
# Exposed as the ``frame_id`` state attribute so the Lovelace card
|
||||
# has an unambiguous "new frame ready" signal even when other
|
||||
# attributes happen not to change between slides.
|
||||
self._frame_id: int = 0
|
||||
|
||||
self._interrupt_event: asyncio.Event = asyncio.Event()
|
||||
self._force_next: bool = False
|
||||
self._consecutive_failures: int = 0
|
||||
self._render_task: asyncio.Task | None = None
|
||||
|
||||
def _on_coordinator_update() -> None:
|
||||
self._effective_cache = None
|
||||
self._interrupt_event.set()
|
||||
self.async_write_ha_state()
|
||||
|
||||
coordinator.async_add_listener(_on_coordinator_update)
|
||||
|
||||
def _on_store_change() -> None:
|
||||
self._download_cache.resize(self.store.image_cache_mb * 1024 * 1024)
|
||||
self._effective_cache = None
|
||||
self._interrupt_event.set()
|
||||
self.async_write_ha_state()
|
||||
|
||||
store.add_listener(_on_store_change)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
await super().async_added_to_hass()
|
||||
# Restore last framebuffer (if the store kept one) so the camera has
|
||||
# something to show immediately after a restart, rather than a broken
|
||||
# image placeholder while the first render completes.
|
||||
restored = getattr(self.store, "last_frame", None)
|
||||
if isinstance(restored, (bytes, bytearray)) and restored:
|
||||
self._framebuffer = bytes(restored)
|
||||
# Stagger the first render across multiple albums so they don't all
|
||||
# decode + encode at the same instant on HA startup. Deterministic
|
||||
# offset based on entry_id keeps the pattern stable across
|
||||
# restarts. Up to ~3 s spread across albums.
|
||||
startup_delay = (hash(self.entry.entry_id) % 3000) / 1000.0
|
||||
self._render_task = self.hass.async_create_background_task(
|
||||
self._render_loop(initial_delay=startup_delay),
|
||||
name="album_slideshow_render_loop",
|
||||
)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
if self._render_task is not None:
|
||||
self._render_task.cancel()
|
||||
try:
|
||||
await self._render_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
@property
|
||||
def device_info(self):
|
||||
return {
|
||||
"identifiers": {(DOMAIN, self.entry.entry_id)},
|
||||
"name": f"Album Slideshow {self.entry.title}",
|
||||
"manufacturer": "Album Slideshow",
|
||||
}
|
||||
|
||||
@property
|
||||
def icon(self) -> str:
|
||||
if self.coordinator.provider == PROVIDER_GOOGLE_SHARED:
|
||||
return "mdi:google-photos"
|
||||
return "mdi:folder-multiple-image"
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self):
|
||||
data = self.coordinator.data or {}
|
||||
items: list[MediaItem] = self._effective_items()
|
||||
cur = items[self._index] if items and 0 <= self._index < len(items) else None
|
||||
captured_at = _ts_to_iso(getattr(cur, "captured_at", None))
|
||||
captured_at_pair = self._last_captured_at_pair
|
||||
return {
|
||||
"album_title": data.get("title"),
|
||||
"media_count": len(items),
|
||||
"media_count_total": len(data.get("items", []) or []),
|
||||
"current_index": self._index,
|
||||
"current_filename": getattr(cur, "filename", None),
|
||||
"current_url": getattr(cur, "url", None),
|
||||
"current_is_portrait": self._last_is_portrait,
|
||||
"captured_at": captured_at_pair if captured_at_pair else captured_at,
|
||||
"captured_at_primary": captured_at,
|
||||
"uploaded_at": _ts_to_iso(getattr(cur, "uploaded_at", None)),
|
||||
"byte_size": getattr(cur, "byte_size", None),
|
||||
# GPS + reverse-geocoded label come from EXIF for local-folder
|
||||
# entries; Google albums leave these as ``None``.
|
||||
"latitude": getattr(cur, "latitude", None),
|
||||
"longitude": getattr(cur, "longitude", None),
|
||||
"location": getattr(cur, "location", None),
|
||||
# Structured per-image caption metadata. A single-element list for
|
||||
# normal slides; two elements (top/left first) for paired slides,
|
||||
# so the card can overlay an accurate date/location on each half.
|
||||
# ``pair_orientation`` tells the card how the two halves are laid
|
||||
# out: ``horizontal`` (left/right) or ``vertical`` (top/bottom).
|
||||
"caption_frames": self._caption_frames(cur, captured_at),
|
||||
"pair_orientation": self._last_pair_orientation,
|
||||
"slide_interval": int(self.store.slide_interval),
|
||||
"fill_mode": self.store.fill_mode,
|
||||
"portrait_mode": self.store.portrait_mode,
|
||||
"order_mode": self.store.order_mode,
|
||||
"date_filter": self.store.date_filter,
|
||||
"paused": bool(self.store.paused),
|
||||
"refresh_hours": int(self.store.refresh_hours),
|
||||
"aspect_ratio": self.store.aspect_ratio,
|
||||
"pair_divider_px": int(self.store.pair_divider_px),
|
||||
"pair_divider_color": self.store.pair_divider_color,
|
||||
"frame_id": self._frame_id,
|
||||
"pagination_debug": data.get("pagination_debug"),
|
||||
}
|
||||
|
||||
def _caption_frames(self, cur, captured_at: str | None) -> list[dict]:
|
||||
"""Per-image caption metadata for the current slide.
|
||||
|
||||
Returns a list with one dict for a normal slide, or two (top/left
|
||||
first) for a paired slide. Each dict carries ``captured_at`` (ISO
|
||||
string or ``None``), ``location`` (human label or ``None``), and
|
||||
``latitude`` / ``longitude``. The card reads this to overlay an
|
||||
accurate caption on each image, including each half of a pair.
|
||||
"""
|
||||
if self._last_pair_frames:
|
||||
return self._last_pair_frames
|
||||
return [
|
||||
{
|
||||
"captured_at": captured_at,
|
||||
"location": getattr(cur, "location", None),
|
||||
"latitude": getattr(cur, "latitude", None),
|
||||
"longitude": getattr(cur, "longitude", None),
|
||||
}
|
||||
]
|
||||
|
||||
@property
|
||||
def entity_picture(self) -> str | None:
|
||||
"""Return the camera proxy URL with a per-frame cache-buster.
|
||||
|
||||
HA core's default ``entity_picture`` only changes when the access
|
||||
token rotates (about every five minutes). Browsers happily serve
|
||||
the cached image to the more-info dialog and other surfaces in
|
||||
between rotations, so they end up showing the previous slide
|
||||
while a fresh slide is already in the framebuffer. Appending the
|
||||
``frame_id`` invalidates that cache as soon as a new slide is
|
||||
committed, no matter where in HA the picture is rendered.
|
||||
"""
|
||||
base = super().entity_picture
|
||||
if not base:
|
||||
return base
|
||||
sep = "&" if "?" in base else "?"
|
||||
return f"{base}{sep}frame={self._frame_id}"
|
||||
|
||||
@property
|
||||
def cache_usage_mb(self) -> float:
|
||||
return round(self._download_cache.total_bytes / (1024 * 1024), 1)
|
||||
|
||||
def _effective_items(self) -> list[MediaItem]:
|
||||
"""Return the playlist after applying the date filter and order mode.
|
||||
|
||||
Cached until the coordinator or store changes (see invalidations
|
||||
wired up in __init__).
|
||||
"""
|
||||
data = self.coordinator.data or {}
|
||||
raw: list[MediaItem] = data.get("items", []) or []
|
||||
cache_key = (
|
||||
id(raw),
|
||||
self.store.date_filter,
|
||||
self.store.order_mode,
|
||||
)
|
||||
if self._effective_cache is not None and self._effective_cache[0] == hash(cache_key):
|
||||
return self._effective_cache[1]
|
||||
|
||||
filtered = playlist.filter_items(
|
||||
raw,
|
||||
mode=self.store.date_filter,
|
||||
)
|
||||
ordered = playlist.order_items(filtered, self.store.order_mode)
|
||||
self._effective_cache = (hash(cache_key), ordered)
|
||||
return ordered
|
||||
|
||||
async def async_force_next(self) -> None:
|
||||
self._force_next = True
|
||||
self._interrupt_event.set()
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_force_refresh(self) -> None:
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_camera_image(self, width: int | None = None, height: int | None = None) -> bytes | None:
|
||||
return self._framebuffer
|
||||
|
||||
async def handle_async_mjpeg_stream(self, request):
|
||||
"""Stream the slideshow as multipart MJPEG.
|
||||
|
||||
Each open client gets a bounded asyncio.Queue that the render loop
|
||||
pushes JPEG payloads into when a new still is committed. Visible
|
||||
transitions are now handled by the Lovelace card on the client
|
||||
side, so this stream just emits the latest still per slide change.
|
||||
"""
|
||||
# Imported lazily so the module still loads in test environments
|
||||
# that stub out homeassistant without installing aiohttp.
|
||||
from aiohttp import web
|
||||
|
||||
boundary = "frame"
|
||||
response = web.StreamResponse(
|
||||
status=200,
|
||||
reason="OK",
|
||||
headers={
|
||||
"Content-Type": f"multipart/x-mixed-replace;boundary={boundary}",
|
||||
"Cache-Control": "no-cache, private",
|
||||
"Pragma": "no-cache",
|
||||
},
|
||||
)
|
||||
await response.prepare(request)
|
||||
|
||||
# Bounded queue: a slow client should fall behind on slide commits
|
||||
# rather than balloon memory.
|
||||
queue: asyncio.Queue[bytes] = asyncio.Queue(maxsize=4)
|
||||
self._mjpeg_subscribers.add(queue)
|
||||
|
||||
# Push the held still immediately so the client renders something
|
||||
# before the next slide change.
|
||||
if self._framebuffer is not None:
|
||||
try:
|
||||
queue.put_nowait(self._framebuffer)
|
||||
except asyncio.QueueFull:
|
||||
pass
|
||||
|
||||
try:
|
||||
while True:
|
||||
payload = await queue.get()
|
||||
try:
|
||||
await response.write(
|
||||
b"--" + boundary.encode() + b"\r\n"
|
||||
b"Content-Type: image/jpeg\r\n"
|
||||
b"Content-Length: " + str(len(payload)).encode() + b"\r\n\r\n"
|
||||
+ payload + b"\r\n"
|
||||
)
|
||||
except (ConnectionResetError, asyncio.CancelledError):
|
||||
raise
|
||||
except Exception as err:
|
||||
_LOGGER.debug("Album Slideshow: mjpeg client write failed: %s", err)
|
||||
break
|
||||
except (ConnectionResetError, asyncio.CancelledError):
|
||||
pass
|
||||
finally:
|
||||
self._mjpeg_subscribers.discard(queue)
|
||||
try:
|
||||
await response.write_eof()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return response
|
||||
|
||||
# Older HA cores may dispatch via the alt name; alias for compatibility.
|
||||
async def async_handle_async_mjpeg_stream(self, request):
|
||||
return await self.handle_async_mjpeg_stream(request)
|
||||
|
||||
async def _wait_or_interrupt(self, timeout: float) -> bool:
|
||||
"""Wait up to ``timeout`` seconds, returning True if interrupted.
|
||||
|
||||
Safe wrapper around clear() + wait_for() - callers don't have to
|
||||
worry about the ordering of the two operations. The clear() runs
|
||||
synchronously before the awaitable is created, so no interrupt can
|
||||
be lost on the single-threaded event loop.
|
||||
"""
|
||||
self._interrupt_event.clear()
|
||||
try:
|
||||
await asyncio.wait_for(self._interrupt_event.wait(), timeout=timeout)
|
||||
return True
|
||||
except asyncio.TimeoutError:
|
||||
return False
|
||||
|
||||
async def _render_loop(self, initial_delay: float = 0.0) -> None:
|
||||
"""Background task: render slides into _framebuffer, advance on timer or interrupt."""
|
||||
if initial_delay > 0:
|
||||
try:
|
||||
await asyncio.sleep(initial_delay)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
should_advance = False # Don't advance on the very first render
|
||||
while True:
|
||||
try:
|
||||
await self._render_cycle(advance=should_advance)
|
||||
self._consecutive_failures = 0
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as err:
|
||||
self._consecutive_failures += 1
|
||||
backoff = min(2 ** self._consecutive_failures, 60)
|
||||
_LOGGER.warning(
|
||||
"Album Slideshow: render cycle failed (attempt %d), retrying in %ds: %s",
|
||||
self._consecutive_failures, backoff, err,
|
||||
)
|
||||
try:
|
||||
await asyncio.sleep(backoff)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
should_advance = True # Skip the broken image on retry
|
||||
continue
|
||||
|
||||
interrupted = await self._wait_or_interrupt(float(int(self.store.slide_interval)))
|
||||
if interrupted:
|
||||
should_advance = self._force_next
|
||||
self._force_next = False
|
||||
else:
|
||||
# Paused slideshows hold the current frame until the user
|
||||
# un-pauses or hits "next slide" explicitly.
|
||||
should_advance = not bool(self.store.paused)
|
||||
|
||||
async def _render_cycle(self, advance: bool) -> None:
|
||||
"""Render one frame.
|
||||
|
||||
The slideshow is just "advance index, compose, encode, broadcast".
|
||||
Visible transitions are handled by the Lovelace card client-side,
|
||||
so this path stays minimal: at most one PIL decode + encode per
|
||||
slide change.
|
||||
|
||||
Compose work is serialised across all albums via a domain-wide
|
||||
semaphore so 4 cameras don't all decode + encode at once.
|
||||
"""
|
||||
items: list[MediaItem] = self._effective_items()
|
||||
if not items:
|
||||
return
|
||||
|
||||
count = len(items)
|
||||
if advance:
|
||||
self._do_advance(count, items)
|
||||
|
||||
async with self._compose_semaphore:
|
||||
composed, meta = await self._compose_for_index(items)
|
||||
if composed is None:
|
||||
return
|
||||
try:
|
||||
await self._commit_composed(composed, meta)
|
||||
finally:
|
||||
ip.safe_close(composed)
|
||||
|
||||
async def _commit_composed(self, composed: Image.Image, meta: dict) -> None:
|
||||
"""Encode the composed slide into the framebuffer and broadcast.
|
||||
|
||||
Encodes off the loop so the JPEG encode (30-80 ms at 1080p, more
|
||||
at 4K) doesn't block HA.
|
||||
"""
|
||||
encoded = await self.hass.async_add_executor_job(
|
||||
ip.encode_image, composed
|
||||
)
|
||||
|
||||
self._framebuffer = encoded
|
||||
self.store.last_frame = encoded
|
||||
self._frame_id += 1
|
||||
if meta:
|
||||
self._last_is_portrait = meta.get("is_portrait")
|
||||
else:
|
||||
self._last_is_portrait = None
|
||||
self._last_captured_at_pair = meta.get("captured_at_pair") if meta else None
|
||||
self._last_pair_frames = meta.get("pair_frames") if meta else None
|
||||
self._last_pair_orientation = meta.get("pair_orientation") if meta else None
|
||||
|
||||
self._broadcast_frame(encoded)
|
||||
self.async_write_ha_state()
|
||||
|
||||
@property
|
||||
def _compose_semaphore(self) -> asyncio.Semaphore:
|
||||
"""Return the domain-wide compose semaphore, creating it on demand.
|
||||
|
||||
``__init__.py`` populates it during setup, but defensive
|
||||
initialisation here means a partially-loaded integration can
|
||||
still render without crashing.
|
||||
"""
|
||||
domain_data = self.hass.data.setdefault(DOMAIN, {})
|
||||
sem = domain_data.get("compose_semaphore")
|
||||
if sem is None:
|
||||
sem = asyncio.Semaphore(1)
|
||||
domain_data["compose_semaphore"] = sem
|
||||
return sem
|
||||
|
||||
def _broadcast_frame(self, payload: bytes) -> None:
|
||||
"""Push a frame to every active MJPEG subscriber.
|
||||
|
||||
Slow subscribers get their frame dropped rather than backing up the
|
||||
queue; the next still emission will catch them up.
|
||||
"""
|
||||
for queue in list(self._mjpeg_subscribers):
|
||||
try:
|
||||
queue.put_nowait(payload)
|
||||
except asyncio.QueueFull:
|
||||
# Drain one and retry once so a wedged client still sees
|
||||
# the latest frame eventually instead of forever stale.
|
||||
try:
|
||||
queue.get_nowait()
|
||||
except asyncio.QueueEmpty:
|
||||
pass
|
||||
try:
|
||||
queue.put_nowait(payload)
|
||||
except asyncio.QueueFull:
|
||||
pass
|
||||
|
||||
def _do_advance(self, count: int, items: list) -> None:
|
||||
"""Advance _index to the next slide and commit random-order position."""
|
||||
if count <= 0:
|
||||
self._index = 0
|
||||
return
|
||||
|
||||
self._index %= count
|
||||
order_mode = self.store.order_mode
|
||||
|
||||
# Sequential modes (album order + sorted-by-time orderings) walk in
|
||||
# order. The list is already pre-sorted by ``order_items``, so we
|
||||
# only need to step forward.
|
||||
if order_mode != ORDER_RANDOM:
|
||||
self._index = (self._index + 1) % count
|
||||
return
|
||||
|
||||
self._index = self._next_random_index(count)
|
||||
cur_url = items[self._index].url
|
||||
self._recent_urls.append(cur_url)
|
||||
keep = min(20, max(1, count - 1))
|
||||
if len(self._recent_urls) > keep:
|
||||
self._recent_urls = self._recent_urls[-keep:]
|
||||
|
||||
def _peek_advance(self, count: int, items: list) -> None:
|
||||
"""Advance _index without committing to random-order bookkeeping.
|
||||
|
||||
Used by the orientation-avoid search so that rejected candidates
|
||||
don't burn through the random cycle and cause premature repeats.
|
||||
"""
|
||||
if count <= 0:
|
||||
self._index = 0
|
||||
return
|
||||
self._index = (self._index + 1) % count
|
||||
|
||||
async def _compose_for_index(
|
||||
self, items: list[MediaItem]
|
||||
) -> tuple[Image.Image | None, dict | None]:
|
||||
"""Compose the slide at ``self._index`` into a PIL image.
|
||||
|
||||
Returns ``(composed, meta)`` where ``meta`` carries the orientation
|
||||
and paired-capture metadata that ``_commit_composed`` will publish
|
||||
as state attributes. Returns ``(None, None)`` if compose failed.
|
||||
|
||||
Pure compose - does NOT mutate ``self._framebuffer`` or
|
||||
``self._last_*`` state. The caller commits via ``_commit_composed``.
|
||||
"""
|
||||
fill_mode = self.store.fill_mode
|
||||
portrait_mode = self.store.portrait_mode
|
||||
divider = max(0, int(self.store.pair_divider_px))
|
||||
divider_fill, transparent_divider = ip.parse_divider_color(self.store.pair_divider_color)
|
||||
max_short_edge = MAX_RESOLUTION_SHORT_EDGE.get(self.store.max_resolution)
|
||||
width, height = ip.resolve_output_size(None, None, self.store.aspect_ratio, max_short_edge)
|
||||
|
||||
cur = items[self._index]
|
||||
is_portrait_canvas = height > width
|
||||
|
||||
# Metadata fast path: if we can resolve orientation without downloading,
|
||||
# we may short-circuit the mismatch handling before any bytes are read.
|
||||
meta_portrait = ip.is_portrait_item_by_metadata(cur)
|
||||
if (
|
||||
meta_portrait is not None
|
||||
and meta_portrait != is_portrait_canvas
|
||||
and portrait_mode == ORIENTATION_MISMATCH_AVOID
|
||||
):
|
||||
return await self._compose_skip_mismatch(items, width, height, fill_mode, is_portrait_canvas)
|
||||
|
||||
cur_bytes = await self._fetch_bytes(cur.url)
|
||||
if not cur_bytes:
|
||||
raise RuntimeError(f"Failed to fetch image: {cur.url}")
|
||||
|
||||
img = await self.hass.async_add_executor_job(
|
||||
ip.open_image, cur_bytes, (width, height)
|
||||
)
|
||||
try:
|
||||
cur_is_portrait = ip.is_portrait_item(cur, img)
|
||||
orientation_mismatch = cur_is_portrait != is_portrait_canvas
|
||||
|
||||
if orientation_mismatch and portrait_mode == ORIENTATION_MISMATCH_AVOID:
|
||||
ip.safe_close(img)
|
||||
img = None
|
||||
return await self._compose_skip_mismatch(items, width, height, fill_mode, is_portrait_canvas)
|
||||
|
||||
if orientation_mismatch and portrait_mode == ORIENTATION_MISMATCH_PAIR:
|
||||
pair = await self._find_next_mismatch_image(
|
||||
items, is_portrait_canvas, width, height, limit=_PAIR_SEARCH_LIMIT
|
||||
)
|
||||
other_img = pair[0] if pair else None
|
||||
other_item = pair[1] if pair else None
|
||||
pair_meta: list[str | None] | None = None
|
||||
pair_frames: list[dict] | None = None
|
||||
try:
|
||||
if other_img is not None:
|
||||
composed = await self.hass.async_add_executor_job(
|
||||
ip.pair_images, img, other_img, width, height, fill_mode,
|
||||
is_portrait_canvas, divider, divider_fill, transparent_divider,
|
||||
)
|
||||
pair_frames = [
|
||||
{
|
||||
"captured_at": _ts_to_iso(getattr(cur, "captured_at", None)),
|
||||
"location": getattr(cur, "location", None),
|
||||
"latitude": getattr(cur, "latitude", None),
|
||||
"longitude": getattr(cur, "longitude", None),
|
||||
},
|
||||
{
|
||||
"captured_at": _ts_to_iso(getattr(other_item, "captured_at", None)),
|
||||
"location": getattr(other_item, "location", None),
|
||||
"latitude": getattr(other_item, "latitude", None),
|
||||
"longitude": getattr(other_item, "longitude", None),
|
||||
},
|
||||
]
|
||||
pair_meta = [f["captured_at"] for f in pair_frames]
|
||||
else:
|
||||
composed = await self.hass.async_add_executor_job(
|
||||
ip.render_image, img, fill_mode, width, height,
|
||||
)
|
||||
finally:
|
||||
ip.safe_close(other_img)
|
||||
meta = {
|
||||
"is_portrait": cur_is_portrait,
|
||||
"captured_at_pair": pair_meta,
|
||||
"pair_frames": pair_frames,
|
||||
# ``pair_images`` stacks images top/bottom on a portrait
|
||||
# canvas and places them left/right on a landscape canvas.
|
||||
"pair_orientation": (
|
||||
("vertical" if is_portrait_canvas else "horizontal")
|
||||
if pair_frames
|
||||
else None
|
||||
),
|
||||
}
|
||||
return composed, meta
|
||||
|
||||
composed = await self.hass.async_add_executor_job(
|
||||
ip.render_image, img, fill_mode, width, height
|
||||
)
|
||||
return composed, {
|
||||
"is_portrait": cur_is_portrait,
|
||||
"captured_at_pair": None,
|
||||
}
|
||||
finally:
|
||||
ip.safe_close(img)
|
||||
|
||||
async def _compose_skip_mismatch(
|
||||
self,
|
||||
items: list[MediaItem],
|
||||
width: int,
|
||||
height: int,
|
||||
fill_mode: str,
|
||||
is_portrait_canvas: bool,
|
||||
) -> tuple[Image.Image | None, dict | None]:
|
||||
"""Skip-mismatch variant of ``_compose_for_index``.
|
||||
|
||||
Walks forward (peek-advancing for non-matches) until it finds an
|
||||
image whose orientation matches the canvas, then composes it.
|
||||
"""
|
||||
count = len(items)
|
||||
if count <= 0:
|
||||
return None, None
|
||||
|
||||
start = self._index
|
||||
|
||||
for _ in range(min(count, _SKIP_SEARCH_LIMIT)):
|
||||
cur = items[self._index]
|
||||
|
||||
meta_portrait = ip.is_portrait_item_by_metadata(cur)
|
||||
if meta_portrait is not None:
|
||||
if meta_portrait != is_portrait_canvas:
|
||||
self._peek_advance(count, items)
|
||||
continue
|
||||
if self._index != start:
|
||||
self._do_advance(count, items)
|
||||
return await self._compose_single(cur, width, height, fill_mode)
|
||||
|
||||
b = await self._fetch_bytes(cur.url)
|
||||
if not b:
|
||||
self._peek_advance(count, items)
|
||||
continue
|
||||
img = await self.hass.async_add_executor_job(ip.open_image, b, (width, height))
|
||||
try:
|
||||
if ip.is_portrait_item(cur, img) != is_portrait_canvas:
|
||||
self._peek_advance(count, items)
|
||||
continue
|
||||
if self._index != start:
|
||||
self._do_advance(count, items)
|
||||
composed = await self.hass.async_add_executor_job(
|
||||
ip.render_image, img, fill_mode, width, height
|
||||
)
|
||||
return composed, {
|
||||
"is_portrait": is_portrait_canvas,
|
||||
"captured_at_pair": None,
|
||||
}
|
||||
finally:
|
||||
ip.safe_close(img)
|
||||
|
||||
self._index = start
|
||||
return await self._compose_single(items[self._index], width, height, fill_mode)
|
||||
|
||||
async def _compose_single(
|
||||
self,
|
||||
item: MediaItem,
|
||||
width: int,
|
||||
height: int,
|
||||
fill_mode: str,
|
||||
) -> tuple[Image.Image | None, dict | None]:
|
||||
b = await self._fetch_bytes(item.url)
|
||||
if not b:
|
||||
return None, None
|
||||
img = await self.hass.async_add_executor_job(ip.open_image, b, (width, height))
|
||||
try:
|
||||
cur_is_portrait = ip.is_portrait_item(item, img)
|
||||
composed = await self.hass.async_add_executor_job(
|
||||
ip.render_image, img, fill_mode, width, height
|
||||
)
|
||||
return composed, {
|
||||
"is_portrait": cur_is_portrait,
|
||||
"captured_at_pair": None,
|
||||
}
|
||||
finally:
|
||||
ip.safe_close(img)
|
||||
|
||||
async def _render_current(self, items: list[MediaItem]) -> bytes | None:
|
||||
"""Compatibility wrapper: compose + encode the current slide.
|
||||
|
||||
Kept as a thin wrapper because external code paths (e.g., tests)
|
||||
may still call it. ``_render_cycle`` no longer does.
|
||||
"""
|
||||
composed, _ = await self._compose_for_index(items)
|
||||
if composed is None:
|
||||
return None
|
||||
try:
|
||||
return await self.hass.async_add_executor_job(ip.encode_image, composed)
|
||||
finally:
|
||||
ip.safe_close(composed)
|
||||
|
||||
async def _find_next_mismatch_image(
|
||||
self,
|
||||
items: list[MediaItem],
|
||||
is_portrait_canvas: bool,
|
||||
width: int,
|
||||
height: int,
|
||||
limit: int = _PAIR_SEARCH_LIMIT,
|
||||
) -> tuple[Image.Image, MediaItem] | None:
|
||||
"""Find an image with the opposite orientation of the canvas.
|
||||
|
||||
Uses metadata wherever possible - only candidates without width/height
|
||||
metadata are downloaded and decoded for their orientation. The returned
|
||||
PIL image is the caller's to close. The matching ``MediaItem`` is
|
||||
returned alongside so the caller can attribute timestamps etc.
|
||||
"""
|
||||
if not items:
|
||||
return None
|
||||
n = len(items)
|
||||
tries = 0
|
||||
offset = 1
|
||||
while tries < limit and offset < n:
|
||||
idx = (self._index + offset) % n
|
||||
it = items[idx]
|
||||
offset += 1
|
||||
tries += 1
|
||||
|
||||
if it.url in self._recent_urls:
|
||||
continue
|
||||
|
||||
meta_portrait = ip.is_portrait_item_by_metadata(it)
|
||||
if meta_portrait is not None and meta_portrait == is_portrait_canvas:
|
||||
# Metadata says this one is the wrong orientation for pairing; skip.
|
||||
continue
|
||||
|
||||
b = await self._fetch_bytes(it.url)
|
||||
if not b:
|
||||
continue
|
||||
|
||||
try:
|
||||
img = await self.hass.async_add_executor_job(ip.open_image, b, (width, height))
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if ip.is_portrait_item(it, img) != is_portrait_canvas:
|
||||
return img, it
|
||||
ip.safe_close(img)
|
||||
|
||||
return None
|
||||
|
||||
def _next_random_index(self, count: int) -> int:
|
||||
if count <= 1:
|
||||
self._random_order = [0]
|
||||
self._random_pos = 0
|
||||
return 0
|
||||
|
||||
needs_new_cycle = len(self._random_order) != count or self._random_pos >= len(self._random_order)
|
||||
if needs_new_cycle:
|
||||
self._random_order = list(range(count))
|
||||
self._rng.shuffle(self._random_order)
|
||||
self._random_pos = 0
|
||||
|
||||
if self._random_order and self._random_order[0] == self._index:
|
||||
self._random_order.append(self._random_order.pop(0))
|
||||
|
||||
idx = self._random_order[self._random_pos]
|
||||
self._random_pos += 1
|
||||
return idx
|
||||
|
||||
async def _fetch_bytes(self, url: str) -> bytes | None:
|
||||
cached = self._download_cache.get(url)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
if url.startswith("file://"):
|
||||
try:
|
||||
p = Path(url[7:])
|
||||
data = await self.hass.async_add_executor_job(p.read_bytes)
|
||||
except Exception as err:
|
||||
_LOGGER.warning("Album Slideshow: failed to read local image: %s", err)
|
||||
return None
|
||||
if len(data) > _MAX_DOWNLOAD_BYTES:
|
||||
_LOGGER.warning(
|
||||
"Album Slideshow: local image %s is %d bytes, exceeds %d byte limit; skipping",
|
||||
url, len(data), _MAX_DOWNLOAD_BYTES,
|
||||
)
|
||||
return None
|
||||
else:
|
||||
data = await self._http_get(url)
|
||||
if data is None:
|
||||
return None
|
||||
|
||||
self._download_cache.put(url, data)
|
||||
return data
|
||||
|
||||
async def _http_get(self, url: str) -> bytes | None:
|
||||
session = async_get_clientsession(self.hass)
|
||||
try:
|
||||
async with async_timeout.timeout(30):
|
||||
async with session.get(url) as resp:
|
||||
resp.raise_for_status()
|
||||
|
||||
content_type = resp.headers.get("Content-Type", "")
|
||||
primary = content_type.split(";", 1)[0].strip().lower()
|
||||
if primary and not primary.startswith(_ACCEPTED_IMAGE_PREFIX):
|
||||
_LOGGER.debug(
|
||||
"Album Slideshow: rejecting %s, content-type %r is not an image",
|
||||
url, primary,
|
||||
)
|
||||
return None
|
||||
|
||||
content_length = resp.headers.get("Content-Length")
|
||||
if content_length is not None:
|
||||
try:
|
||||
declared = int(content_length)
|
||||
except ValueError:
|
||||
declared = -1
|
||||
if declared > _MAX_DOWNLOAD_BYTES:
|
||||
_LOGGER.warning(
|
||||
"Album Slideshow: %s advertises %d bytes, exceeds %d byte limit; skipping",
|
||||
url, declared, _MAX_DOWNLOAD_BYTES,
|
||||
)
|
||||
return None
|
||||
|
||||
chunks: list[bytes] = []
|
||||
total = 0
|
||||
async for chunk in resp.content.iter_chunked(64 * 1024):
|
||||
total += len(chunk)
|
||||
if total > _MAX_DOWNLOAD_BYTES:
|
||||
_LOGGER.warning(
|
||||
"Album Slideshow: %s exceeded %d byte limit mid-download; aborting",
|
||||
url, _MAX_DOWNLOAD_BYTES,
|
||||
)
|
||||
return None
|
||||
chunks.append(chunk)
|
||||
return b"".join(chunks)
|
||||
except Exception as err:
|
||||
_LOGGER.warning("Album Slideshow: failed to fetch image: %s", err)
|
||||
return None
|
||||
@@ -0,0 +1,194 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
CONF_PROVIDER,
|
||||
CONF_ALBUM_NAME,
|
||||
CONF_ALBUM_URL,
|
||||
CONF_LOCAL_PATH,
|
||||
CONF_RECURSIVE,
|
||||
CONF_REVERSE_GEOCODE,
|
||||
DEFAULT_REVERSE_GEOCODE,
|
||||
PROVIDER_GOOGLE_SHARED,
|
||||
PROVIDER_LOCAL_FOLDER,
|
||||
DEFAULT_RECURSIVE,
|
||||
)
|
||||
|
||||
|
||||
def _normalize_local_path(hass, path: str) -> str:
|
||||
p = path.strip()
|
||||
if p.startswith("/local/"):
|
||||
p = "/config/www/" + p[len("/local/"):]
|
||||
elif p == "/local":
|
||||
p = "/config/www"
|
||||
elif p.startswith("local/"):
|
||||
p = "/config/www/" + p[len("local/"):]
|
||||
elif p.startswith("/media/local/"):
|
||||
p = "/media/" + p[len("/media/local/"):]
|
||||
elif p.startswith("media/local/"):
|
||||
p = "/media/" + p[len("media/local/"):]
|
||||
elif p.startswith("media/"):
|
||||
p = "/media/" + p[len("media/"):]
|
||||
elif p == "media":
|
||||
p = "/media"
|
||||
if not p.startswith("/"):
|
||||
p = hass.config.path(p)
|
||||
return p
|
||||
|
||||
|
||||
ALBUM_URL_RE = re.compile(r"^https?://photos\.app\.goo\.gl/[^/]+/?$")
|
||||
|
||||
|
||||
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
VERSION = 1
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._provider: str | None = None
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(
|
||||
config_entry: config_entries.ConfigEntry,
|
||||
) -> config_entries.OptionsFlow:
|
||||
"""Return the options flow handler.
|
||||
|
||||
Only local-folder entries expose user-tunable options today (the
|
||||
reverse-geocode toggle); Google entries get a no-op handler so
|
||||
that the "Configure" button doesn't appear empty in the UI.
|
||||
|
||||
Note: do NOT pass ``config_entry`` to the OptionsFlow constructor.
|
||||
Since Home Assistant 2024.12 the base class manages
|
||||
``self.config_entry`` as a property and assigning to it in
|
||||
``__init__`` raises (the symptom is a 500 when the user clicks
|
||||
Configure).
|
||||
"""
|
||||
if config_entry.data.get(CONF_PROVIDER) == PROVIDER_LOCAL_FOLDER:
|
||||
return LocalFolderOptionsFlow()
|
||||
return _NoOptionsFlow()
|
||||
|
||||
async def async_step_user(self, user_input: dict[str, Any] | None = None) -> FlowResult:
|
||||
if user_input is not None:
|
||||
self._provider = user_input[CONF_PROVIDER]
|
||||
if self._provider == PROVIDER_LOCAL_FOLDER:
|
||||
return await self.async_step_local_folder()
|
||||
return await self.async_step_google_shared()
|
||||
|
||||
schema = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_PROVIDER, default=PROVIDER_GOOGLE_SHARED): vol.In({
|
||||
PROVIDER_GOOGLE_SHARED: "Google Photos",
|
||||
PROVIDER_LOCAL_FOLDER: "Local Folder",
|
||||
})
|
||||
}
|
||||
)
|
||||
return self.async_show_form(step_id="user", data_schema=schema)
|
||||
|
||||
async def async_step_google_shared(self, user_input: dict[str, Any] | None = None) -> FlowResult:
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
url = user_input[CONF_ALBUM_URL].strip()
|
||||
name = user_input[CONF_ALBUM_NAME].strip()
|
||||
|
||||
if not ALBUM_URL_RE.match(url):
|
||||
errors[CONF_ALBUM_URL] = "invalid_album_url"
|
||||
else:
|
||||
await self.async_set_unique_id(f"{DOMAIN}:{PROVIDER_GOOGLE_SHARED}:{url}")
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(
|
||||
title=name,
|
||||
data={
|
||||
CONF_PROVIDER: PROVIDER_GOOGLE_SHARED,
|
||||
CONF_ALBUM_URL: url,
|
||||
CONF_ALBUM_NAME: name,
|
||||
},
|
||||
)
|
||||
|
||||
schema = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_ALBUM_NAME): str,
|
||||
vol.Required(CONF_ALBUM_URL): str,
|
||||
}
|
||||
)
|
||||
return self.async_show_form(step_id="google_shared", data_schema=schema, errors=errors)
|
||||
|
||||
async def async_step_local_folder(self, user_input: dict[str, Any] | None = None) -> FlowResult:
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
path = _normalize_local_path(self.hass, user_input[CONF_LOCAL_PATH])
|
||||
name = user_input[CONF_ALBUM_NAME].strip()
|
||||
recursive = bool(user_input.get(CONF_RECURSIVE, DEFAULT_RECURSIVE))
|
||||
|
||||
if not path:
|
||||
errors[CONF_LOCAL_PATH] = "invalid_path"
|
||||
else:
|
||||
await self.async_set_unique_id(f"{DOMAIN}:{PROVIDER_LOCAL_FOLDER}:{path}")
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(
|
||||
title=name,
|
||||
data={
|
||||
CONF_PROVIDER: PROVIDER_LOCAL_FOLDER,
|
||||
CONF_LOCAL_PATH: path,
|
||||
CONF_RECURSIVE: recursive,
|
||||
CONF_ALBUM_NAME: name,
|
||||
},
|
||||
)
|
||||
|
||||
schema = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_ALBUM_NAME): str,
|
||||
vol.Required(CONF_LOCAL_PATH): str,
|
||||
vol.Optional(CONF_RECURSIVE, default=DEFAULT_RECURSIVE): bool,
|
||||
}
|
||||
)
|
||||
return self.async_show_form(step_id="local_folder", data_schema=schema, errors=errors)
|
||||
|
||||
|
||||
class LocalFolderOptionsFlow(config_entries.OptionsFlow):
|
||||
"""Options for local-folder entries.
|
||||
|
||||
Currently exposes a single toggle: ``reverse_geocode``. Users with
|
||||
privacy concerns about handing EXIF GPS coordinates to an external
|
||||
OSM endpoint can turn this off; the GPS coordinates remain available
|
||||
as ``latitude``/``longitude`` attributes regardless.
|
||||
|
||||
``self.config_entry`` is provided by ``OptionsFlow`` as a managed
|
||||
property (HA 2024.12+); we deliberately do NOT define ``__init__``
|
||||
or assign to it, since doing so raises in newer cores.
|
||||
"""
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(title="", data=user_input)
|
||||
|
||||
current = self.config_entry.options.get(
|
||||
CONF_REVERSE_GEOCODE, DEFAULT_REVERSE_GEOCODE
|
||||
)
|
||||
schema = vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_REVERSE_GEOCODE, default=bool(current)
|
||||
): bool,
|
||||
}
|
||||
)
|
||||
return self.async_show_form(step_id="init", data_schema=schema)
|
||||
|
||||
|
||||
class _NoOptionsFlow(config_entries.OptionsFlow):
|
||||
"""Fallback options flow for providers that expose nothing tunable."""
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
return self.async_create_entry(title="", data={})
|
||||
@@ -0,0 +1,94 @@
|
||||
DOMAIN = "album_slideshow"
|
||||
|
||||
CONF_PROVIDER = "provider"
|
||||
CONF_ALBUM_URL = "album_url"
|
||||
CONF_ALBUM_NAME = "album_name"
|
||||
CONF_LOCAL_PATH = "local_path"
|
||||
CONF_RECURSIVE = "recursive"
|
||||
CONF_IMAGE_CACHE_MB = "image_cache_mb"
|
||||
# Local-folder option: when True (default) the coordinator does best-effort
|
||||
# reverse geocoding of EXIF GPS coordinates via the public Nominatim
|
||||
# (OpenStreetMap) endpoint and exposes a human-readable ``location``
|
||||
# attribute. Users with privacy concerns can disable this from the
|
||||
# integration's options dialog.
|
||||
CONF_REVERSE_GEOCODE = "reverse_geocode"
|
||||
DEFAULT_REVERSE_GEOCODE = True
|
||||
|
||||
PROVIDER_GOOGLE_SHARED = "google_shared"
|
||||
PROVIDER_LOCAL_FOLDER = "local_folder"
|
||||
|
||||
FILL_COVER = "cover"
|
||||
FILL_CONTAIN = "contain"
|
||||
FILL_BLUR = "blur"
|
||||
|
||||
ORIENTATION_MISMATCH_PAIR = "pair"
|
||||
ORIENTATION_MISMATCH_SINGLE = "single"
|
||||
ORIENTATION_MISMATCH_AVOID = "avoid"
|
||||
|
||||
ORDER_RANDOM = "random"
|
||||
ORDER_ALBUM = "album_order"
|
||||
ORDER_NEWEST_TAKEN = "newest_taken"
|
||||
ORDER_OLDEST_TAKEN = "oldest_taken"
|
||||
ORDER_NEWEST_ADDED = "newest_added"
|
||||
ORDER_OLDEST_ADDED = "oldest_added"
|
||||
|
||||
ORDER_OPTIONS = [
|
||||
ORDER_RANDOM,
|
||||
ORDER_ALBUM,
|
||||
ORDER_NEWEST_TAKEN,
|
||||
ORDER_OLDEST_TAKEN,
|
||||
ORDER_NEWEST_ADDED,
|
||||
ORDER_OLDEST_ADDED,
|
||||
]
|
||||
|
||||
DATE_FILTER_OFF = "off"
|
||||
DATE_FILTER_LAST_7 = "last_7_days"
|
||||
DATE_FILTER_LAST_30 = "last_30_days"
|
||||
DATE_FILTER_LAST_365 = "last_365_days"
|
||||
DATE_FILTER_THIS_MONTH = "this_month"
|
||||
DATE_FILTER_THIS_YEAR = "this_year"
|
||||
DATE_FILTER_ON_THIS_DAY = "on_this_day"
|
||||
|
||||
DATE_FILTER_OPTIONS = [
|
||||
DATE_FILTER_OFF,
|
||||
DATE_FILTER_LAST_7,
|
||||
DATE_FILTER_LAST_30,
|
||||
DATE_FILTER_LAST_365,
|
||||
DATE_FILTER_THIS_MONTH,
|
||||
DATE_FILTER_THIS_YEAR,
|
||||
DATE_FILTER_ON_THIS_DAY,
|
||||
]
|
||||
DEFAULT_DATE_FILTER = DATE_FILTER_OFF
|
||||
|
||||
DEFAULT_SLIDE_INTERVAL = 60
|
||||
DEFAULT_REFRESH_HOURS = 24
|
||||
DEFAULT_FILL_MODE = FILL_BLUR
|
||||
DEFAULT_ORIENTATION_MISMATCH_MODE = ORIENTATION_MISMATCH_PAIR
|
||||
DEFAULT_ORDER_MODE = ORDER_RANDOM
|
||||
DEFAULT_ASPECT_RATIO = "16:9"
|
||||
DEFAULT_PAIR_DIVIDER_PX = 8
|
||||
DEFAULT_PAIR_DIVIDER_COLOR = "#FFFFFF"
|
||||
DEFAULT_RECURSIVE = True
|
||||
# Per-album download cache. Multiple albums add up: 4 × 150 MB = 600 MB
|
||||
# of just-in-case downloaded JPEGs. 75 MB caches roughly 10-20 photos
|
||||
# at typical Google Photos resolutions, which is enough for the preload
|
||||
# path. Users with one album and lots of RAM can bump this via the
|
||||
# Image cache size number entity.
|
||||
DEFAULT_IMAGE_CACHE_MB = 75
|
||||
|
||||
MAX_RESOLUTION_OPTIONS = ["480p", "720p", "1080p", "1440p", "4K (2160p)", "original"]
|
||||
DEFAULT_MAX_RESOLUTION = "1080p"
|
||||
MAX_RESOLUTION_SHORT_EDGE: dict[str, int | None] = {
|
||||
"480p": 480,
|
||||
"720p": 720,
|
||||
"1080p": 1080,
|
||||
"1440p": 1440,
|
||||
"4K (2160p)": 2160,
|
||||
"original": None,
|
||||
}
|
||||
|
||||
PUBLICALBUM_ENDPOINT = "https://www.publicalbum.org/api/v2/webapp/embed-player/jsonrpc"
|
||||
|
||||
SERVICE_NEXT_SLIDE = "next_slide"
|
||||
SERVICE_REFRESH_ALBUM = "refresh_album"
|
||||
ATTR_ENTRY_ID = "entry_id"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,472 @@
|
||||
"""Google Photos shared album client that bypasses the ~300 item HTML cap.
|
||||
|
||||
Strategy
|
||||
--------
|
||||
1. Fetch the share URL HTML (with browser User-Agent so Google serves the
|
||||
real page, not a JS-only shim).
|
||||
2. Extract the album media key and auth key from the embedded
|
||||
``AF_dataServiceRequests`` blob - the same parameters Google's JS uses
|
||||
when it scrolls and needs more pages.
|
||||
3. Page through the album by POSTing to the public ``batchexecute`` endpoint
|
||||
with the ``snAcKc`` RPC (the same one googlephotos.com calls). Each page
|
||||
carries up to 300 items plus a continuation token; we loop until the
|
||||
token is empty.
|
||||
|
||||
This mirrors the approach used by community projects like
|
||||
``xob0t/google-photos-toolkit``. It uses only undocumented public endpoints
|
||||
and no auth.
|
||||
|
||||
Brittleness
|
||||
-----------
|
||||
Google occasionally reshuffles the per-item array layout. We keep parsing
|
||||
positional but verify each field at access time and skip malformed entries
|
||||
rather than failing the whole batch.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
|
||||
from .coordinator import MediaItem
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_BROWSER_UA = (
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/124.0.0.0 Safari/537.36"
|
||||
)
|
||||
|
||||
# AF_dataServiceRequests block in the HTML carries the snAcKc request payload:
|
||||
# "snAcKc",ext: ... ,request:["<albumKey>",null,null,"<authKey>"]
|
||||
_REQUEST_RE = re.compile(
|
||||
r"snAcKc[^}]*?request:\s*\[\s*\"([A-Za-z0-9_-]+)\"\s*,\s*null\s*,\s*null\s*,\s*\"([A-Za-z0-9_-]+)\"",
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
# XSSI prefix Google prepends to batchexecute responses. We strip it before parsing.
|
||||
_XSSI = ")]}'"
|
||||
|
||||
# Hard ceiling - matches the upstream Google album item limit.
|
||||
_MAX_ITEMS = 20_000
|
||||
|
||||
# Per-page item count Google returns. Used only for sanity logging.
|
||||
_PAGE_SIZE = 300
|
||||
|
||||
# Strip any existing ``=w...-h...`` suffix from a Google CDN URL so we can
|
||||
# attach our own size hint based on the photo's known dimensions.
|
||||
_SIZE_SUFFIX_RE = re.compile(r"=[wh]\d+(?:-[a-z0-9]+)*$", re.IGNORECASE)
|
||||
|
||||
_VIDEO_DURATION_KEY = 76647426 # presence indicates a video; we skip those
|
||||
_LIVEPHOTO_KEY = 146008172
|
||||
|
||||
|
||||
class _AlbumKeys:
|
||||
__slots__ = ("album_key", "auth_key")
|
||||
|
||||
def __init__(self, album_key: str, auth_key: str) -> None:
|
||||
self.album_key = album_key
|
||||
self.auth_key = auth_key
|
||||
|
||||
|
||||
async def fetch_album(session, share_url: str, *, timeout: float = 30.0) -> tuple[str | None, list[MediaItem]]:
|
||||
"""Fetch a shared album in full. Returns (title, items).
|
||||
|
||||
The HTML page is fetched once - just to recover the album/auth keys and
|
||||
title. All actual photo enumeration goes through Google's ``batchexecute``
|
||||
endpoint, which is the only way to reach photos beyond the first ~300.
|
||||
"""
|
||||
keys, title = await _fetch_album_keys(session, share_url, timeout=timeout)
|
||||
if keys is None:
|
||||
return None, []
|
||||
|
||||
items: list[MediaItem] = []
|
||||
seen_urls: set[str] = set()
|
||||
page_id: str | None = None
|
||||
page_no = 0
|
||||
while True:
|
||||
page_no += 1
|
||||
try:
|
||||
page_items, page_id = await _fetch_album_page(
|
||||
session, keys, page_id, timeout=timeout
|
||||
)
|
||||
except Exception as err:
|
||||
_LOGGER.warning(
|
||||
"Album scrape: page %d batchexecute failed (%s); returning %d items so far",
|
||||
page_no, err, len(items),
|
||||
)
|
||||
break
|
||||
|
||||
added = 0
|
||||
for it in page_items:
|
||||
if it.url in seen_urls:
|
||||
continue
|
||||
seen_urls.add(it.url)
|
||||
items.append(it)
|
||||
added += 1
|
||||
if len(items) >= _MAX_ITEMS:
|
||||
break
|
||||
_LOGGER.debug(
|
||||
"Album scrape: page %d returned %d items (%d new), running total %d",
|
||||
page_no, len(page_items), added, len(items),
|
||||
)
|
||||
if not page_id or len(items) >= _MAX_ITEMS or added == 0:
|
||||
break
|
||||
|
||||
_LOGGER.info(
|
||||
"Album scraper: batchexecute fetched %d photos in %d page(s)",
|
||||
len(items), page_no,
|
||||
)
|
||||
return title, items
|
||||
|
||||
|
||||
# -- Internals ---------------------------------------------------------------
|
||||
|
||||
async def _fetch_album_keys(
|
||||
session, share_url: str, *, timeout: float
|
||||
) -> tuple[_AlbumKeys | None, str | None]:
|
||||
"""Fetch the share URL HTML and extract the album/auth keys + title."""
|
||||
headers = {
|
||||
"User-Agent": _BROWSER_UA,
|
||||
"Accept": "text/html,application/xhtml+xml",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
}
|
||||
try:
|
||||
async with session.get(
|
||||
share_url, headers=headers, timeout=timeout, allow_redirects=True
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
ct = resp.headers.get("Content-Type", "").lower()
|
||||
if "html" not in ct and "text" not in ct:
|
||||
_LOGGER.debug("Album scrape: unexpected content-type %r", ct)
|
||||
return None, None
|
||||
html = await resp.text()
|
||||
except Exception as err:
|
||||
_LOGGER.debug("Album scrape: failed to fetch %s: %s", share_url, err)
|
||||
return None, None
|
||||
|
||||
keys = _extract_keys(html)
|
||||
if keys is None:
|
||||
_LOGGER.debug("Album scrape: could not locate album keys in HTML")
|
||||
return None, None
|
||||
|
||||
return keys, _extract_title(html)
|
||||
|
||||
|
||||
def _extract_keys(html: str) -> _AlbumKeys | None:
|
||||
m = _REQUEST_RE.search(html)
|
||||
if not m:
|
||||
return None
|
||||
return _AlbumKeys(album_key=m.group(1), auth_key=m.group(2))
|
||||
|
||||
|
||||
def _extract_title(html: str) -> str | None:
|
||||
m = re.search(r"<title[^>]*>(.*?)</title>", html, re.IGNORECASE | re.DOTALL)
|
||||
if not m:
|
||||
return None
|
||||
title = m.group(1).strip()
|
||||
suffix = " - Google Photos"
|
||||
if title.endswith(suffix):
|
||||
title = title[: -len(suffix)].strip()
|
||||
return title or None
|
||||
|
||||
|
||||
def _extract_first_page_items(html: str) -> list[MediaItem]:
|
||||
"""Pull the first 300 items out of the AF_initDataCallback blocks.
|
||||
|
||||
Returns an empty list if the embedded data can't be parsed - the caller
|
||||
will still get the rest via batchexecute pagination.
|
||||
"""
|
||||
candidates: list[list[Any]] = []
|
||||
for blob in _iter_af_data_blobs(html):
|
||||
try:
|
||||
tree = json.loads(blob)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
candidates.extend(_collect_album_item_lists(tree))
|
||||
|
||||
if not candidates:
|
||||
return []
|
||||
|
||||
best = max(candidates, key=len)
|
||||
items: list[MediaItem] = []
|
||||
seen: set[str] = set()
|
||||
for raw in best:
|
||||
item = _parse_album_item(raw)
|
||||
if item is None or item.url in seen:
|
||||
continue
|
||||
seen.add(item.url)
|
||||
items.append(item)
|
||||
return items
|
||||
|
||||
|
||||
def _next_page_token_for_first_page(
|
||||
first_items: list[MediaItem], first_page_size: int
|
||||
) -> str | None:
|
||||
"""The first page's nextPageId isn't easy to find in the HTML AF blob.
|
||||
|
||||
Strategy: if the first page is exactly the standard page size we use a
|
||||
sentinel empty token so the caller fetches page 2 with ``pageId=None``.
|
||||
The batchexecute endpoint, given ``pageId=None``, returns page 1 again
|
||||
along with the real continuation token, which we then use for the rest.
|
||||
Slightly wasteful (we re-fetch page 1) but robust to layout changes.
|
||||
"""
|
||||
if first_page_size >= _PAGE_SIZE:
|
||||
return "" # sentinel: drives the first batchexecute call
|
||||
return None
|
||||
|
||||
|
||||
async def _fetch_album_page(
|
||||
session,
|
||||
keys: _AlbumKeys,
|
||||
page_id: str | None,
|
||||
*,
|
||||
timeout: float,
|
||||
) -> tuple[list[MediaItem], str | None]:
|
||||
"""Call snAcKc once. ``page_id=""`` is treated as ``None`` (initial fetch)."""
|
||||
pid = page_id or None
|
||||
inner = json.dumps([keys.album_key, pid, None, keys.auth_key])
|
||||
envelope = json.dumps([[["snAcKc", inner, None, "generic"]]])
|
||||
form = f"f.req={quote(envelope)}"
|
||||
|
||||
url = (
|
||||
"https://photos.google.com/u/0/_/PhotosUi/data/batchexecute"
|
||||
f"?rpcids=snAcKc&source-path=/share/{quote(keys.album_key)}"
|
||||
)
|
||||
headers = {
|
||||
"User-Agent": _BROWSER_UA,
|
||||
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
|
||||
"Accept": "*/*",
|
||||
"Origin": "https://photos.google.com",
|
||||
"Referer": f"https://photos.google.com/share/{keys.album_key}?key={keys.auth_key}",
|
||||
}
|
||||
async with session.post(url, data=form, headers=headers, timeout=timeout) as resp:
|
||||
resp.raise_for_status()
|
||||
body = await resp.text()
|
||||
return _parse_batchexecute_album_page(body)
|
||||
|
||||
|
||||
def _parse_batchexecute_album_page(body: str) -> tuple[list[MediaItem], str | None]:
|
||||
"""Parse a batchexecute response for one snAcKc call.
|
||||
|
||||
Format (per line, after the XSSI prefix):
|
||||
[["wrb.fr", "snAcKc", "<json-encoded inner>", null, null, "generic"], ...]
|
||||
The inner data shape (per gptk-toolkit):
|
||||
data[1] = list of album items
|
||||
data[2] = nextPageId (str) or null
|
||||
"""
|
||||
text = body.lstrip()
|
||||
if text.startswith(_XSSI):
|
||||
text = text[len(_XSSI):]
|
||||
text = text.lstrip()
|
||||
|
||||
line = text.split("\n", 1)[0].strip()
|
||||
if not line:
|
||||
return [], None
|
||||
try:
|
||||
outer = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
return [], None
|
||||
|
||||
inner_json: str | None = None
|
||||
for entry in outer:
|
||||
if isinstance(entry, list) and len(entry) >= 3 and entry[0] == "wrb.fr":
|
||||
inner_json = entry[2]
|
||||
break
|
||||
if not isinstance(inner_json, str):
|
||||
return [], None
|
||||
|
||||
try:
|
||||
inner = json.loads(inner_json)
|
||||
except json.JSONDecodeError:
|
||||
return [], None
|
||||
|
||||
raw_items = inner[1] if len(inner) > 1 and isinstance(inner[1], list) else []
|
||||
next_page = inner[2] if len(inner) > 2 and isinstance(inner[2], str) else None
|
||||
if next_page == "":
|
||||
next_page = None
|
||||
|
||||
items: list[MediaItem] = []
|
||||
for raw in raw_items:
|
||||
item = _parse_album_item(raw)
|
||||
if item is not None:
|
||||
items.append(item)
|
||||
return items, next_page
|
||||
|
||||
|
||||
def _parse_album_item(raw: Any) -> MediaItem | None:
|
||||
"""Parse a single album item array.
|
||||
|
||||
Layout used in both AF blocks and snAcKc responses:
|
||||
[mediaKey, [url, w, h, ..., [byte_size]], captured_ms, dedupKey,
|
||||
timezoneOffsetMin, uploaded_ms, ..., {<numeric_keys>: ...}]
|
||||
"""
|
||||
if not isinstance(raw, list) or len(raw) < 2:
|
||||
return None
|
||||
visual = raw[1]
|
||||
if not isinstance(visual, list) or len(visual) < 3:
|
||||
return None
|
||||
|
||||
url = visual[0]
|
||||
if not isinstance(url, str) or not url.startswith("http"):
|
||||
return None
|
||||
width = visual[1] if isinstance(visual[1], int) else None
|
||||
height = visual[2] if isinstance(visual[2], int) else None
|
||||
|
||||
# Skip videos: their last element is a dict with key 76647426 (duration).
|
||||
if raw and isinstance(raw[-1], dict):
|
||||
if _VIDEO_DURATION_KEY in raw[-1] or "76647426" in raw[-1]:
|
||||
# Note: live photos also carry a duration but are still images;
|
||||
# we treat the presence of duration as "video". If a user reports
|
||||
# missing live photos we can revisit by checking 146008172 too.
|
||||
return None
|
||||
|
||||
captured_at = raw[2] if len(raw) > 2 and _looks_like_timestamp_ms(raw[2]) else None
|
||||
uploaded_at = raw[5] if len(raw) > 5 and _looks_like_timestamp_ms(raw[5]) else None
|
||||
|
||||
# File size, when present, lives at visual[-1][0] as a single int.
|
||||
byte_size: int | None = None
|
||||
if visual and isinstance(visual[-1], list) and visual[-1]:
|
||||
candidate = visual[-1][0]
|
||||
if isinstance(candidate, int) and candidate > 0:
|
||||
byte_size = candidate
|
||||
|
||||
return MediaItem(
|
||||
url=_normalise_size(url, width, height),
|
||||
width=width,
|
||||
height=height,
|
||||
mime_type=None,
|
||||
filename=None,
|
||||
captured_at=captured_at,
|
||||
uploaded_at=uploaded_at,
|
||||
byte_size=byte_size,
|
||||
)
|
||||
|
||||
|
||||
# Plausible epoch-ms range: 2000-01-01 to 2100-01-01.
|
||||
_MIN_TS_MS = 946_684_800_000
|
||||
_MAX_TS_MS = 4_102_444_800_000
|
||||
|
||||
|
||||
def _looks_like_timestamp_ms(value: Any) -> bool:
|
||||
return isinstance(value, int) and _MIN_TS_MS <= value <= _MAX_TS_MS
|
||||
|
||||
|
||||
# -- AF block parsing (for the initial 300 items embedded in the HTML) ------
|
||||
|
||||
_AF_BLOCK_RE = re.compile(r"AF_initDataCallback\s*\(\s*\{", re.DOTALL)
|
||||
_DATA_KEY_RE = re.compile(r"[\s,{]data\s*:\s*\[", re.DOTALL)
|
||||
|
||||
|
||||
def _iter_af_data_blobs(html: str):
|
||||
"""Yield the JSON text of every AF_initDataCallback ``data:`` value."""
|
||||
for m in _AF_BLOCK_RE.finditer(html):
|
||||
block_end = _balanced_close(html, m.end() - 1, "{", "}")
|
||||
if block_end is None:
|
||||
continue
|
||||
block = html[m.end() - 1: block_end + 1]
|
||||
data_match = _DATA_KEY_RE.search(block)
|
||||
if data_match is None:
|
||||
continue
|
||||
open_pos = data_match.end() - 1
|
||||
close_pos = _balanced_close(block, open_pos, "[", "]")
|
||||
if close_pos is None:
|
||||
continue
|
||||
yield block[open_pos: close_pos + 1]
|
||||
|
||||
|
||||
def _balanced_close(s: str, open_idx: int, open_char: str, close_char: str) -> int | None:
|
||||
if open_idx >= len(s) or s[open_idx] != open_char:
|
||||
return None
|
||||
depth = 0
|
||||
in_string: str | None = None
|
||||
i = open_idx
|
||||
n = len(s)
|
||||
while i < n:
|
||||
c = s[i]
|
||||
if in_string is not None:
|
||||
if c == "\\":
|
||||
i += 2
|
||||
continue
|
||||
if c == in_string:
|
||||
in_string = None
|
||||
i += 1
|
||||
continue
|
||||
if c in ("'", '"'):
|
||||
in_string = c
|
||||
i += 1
|
||||
continue
|
||||
if c == open_char:
|
||||
depth += 1
|
||||
elif c == close_char:
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return i
|
||||
i += 1
|
||||
return None
|
||||
|
||||
|
||||
def _collect_album_item_lists(node: Any, _out: list[list[Any]] | None = None) -> list[list[Any]]:
|
||||
"""Walk the AF data tree, collecting lists of album-item-shaped entries."""
|
||||
out = _out if _out is not None else []
|
||||
if isinstance(node, list):
|
||||
if _list_looks_like_album_items(node):
|
||||
out.append(node)
|
||||
for child in node:
|
||||
_collect_album_item_lists(child, out)
|
||||
elif isinstance(node, dict):
|
||||
for child in node.values():
|
||||
_collect_album_item_lists(child, out)
|
||||
return out
|
||||
|
||||
|
||||
def _list_looks_like_album_items(lst: list[Any]) -> bool:
|
||||
if not lst:
|
||||
return False
|
||||
sample = lst[:20]
|
||||
for item in sample:
|
||||
if _parse_album_item(item) is None:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
# -- URL normalisation -------------------------------------------------------
|
||||
|
||||
def _normalise_size(url: str, width: int | None, height: int | None) -> str:
|
||||
"""Strip any existing size suffix and request a 4K-capped version."""
|
||||
base = _SIZE_SUFFIX_RE.sub("", url)
|
||||
if width and height:
|
||||
try:
|
||||
w = int(width)
|
||||
h = int(height)
|
||||
except (TypeError, ValueError):
|
||||
return f"{base}=w1920-h1080"
|
||||
longest = max(w, h)
|
||||
if longest > 3840:
|
||||
scale = 3840 / longest
|
||||
w = max(1, int(round(w * scale)))
|
||||
h = max(1, int(round(h * scale)))
|
||||
return f"{base}=w{w}-h{h}"
|
||||
return f"{base}=w1920-h1080"
|
||||
|
||||
|
||||
# Backwards-compatible names so the existing tests still find the helpers.
|
||||
def parse_album_html(html: str) -> list[MediaItem]:
|
||||
"""Parse only the first-page items embedded in the HTML.
|
||||
|
||||
Retained for backwards compatibility with the 0.5.0-rc1 surface; new
|
||||
callers should use ``fetch_album`` for the full paginated result.
|
||||
"""
|
||||
return _extract_first_page_items(html)
|
||||
|
||||
|
||||
_PHOTO_HOST_RE = re.compile(
|
||||
r"^https?://(?:[a-z0-9-]+\.)?(?:googleusercontent\.com|google\.com)/",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _is_dimension(v: Any) -> bool:
|
||||
return isinstance(v, int) and 16 <= v <= 20_000
|
||||
@@ -0,0 +1,277 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import logging
|
||||
|
||||
from PIL import Image, ImageColor, ImageFilter, ImageOps
|
||||
|
||||
from .coordinator import MediaItem
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Re-export fill mode constants so callers can import from here.
|
||||
FILL_COVER = "cover"
|
||||
FILL_CONTAIN = "contain"
|
||||
FILL_BLUR = "blur"
|
||||
|
||||
# Absolute pixel ceiling. A 20000x20000 JPEG decodes to ~1.2 GB of RGB; Pillow
|
||||
# raises DecompressionBombError above MAX_IMAGE_PIXELS. We set this high enough
|
||||
# that 4K+ sources still decode, but reject anything absurd to protect
|
||||
# low-memory devices like the Home Assistant Green.
|
||||
_MAX_IMAGE_PIXELS = 80_000_000 # ~8K x 10K
|
||||
Image.MAX_IMAGE_PIXELS = _MAX_IMAGE_PIXELS
|
||||
|
||||
|
||||
def open_image(
|
||||
data: bytes,
|
||||
target_size: tuple[int, int] | None = None,
|
||||
) -> Image.Image:
|
||||
"""Open image bytes, apply EXIF orientation, normalise to RGB/RGBA.
|
||||
|
||||
If ``target_size`` is given, uses PIL's ``draft`` mode so libjpeg decodes
|
||||
at a reduced scale. Big speed/memory win on low-power devices when the
|
||||
source is much larger than the output canvas.
|
||||
"""
|
||||
img = Image.open(io.BytesIO(data))
|
||||
if target_size is not None and img.format == "JPEG":
|
||||
try:
|
||||
img.draft("RGB", target_size)
|
||||
except Exception:
|
||||
pass
|
||||
img = ImageOps.exif_transpose(img)
|
||||
# Force pixel data into memory; BytesIO must stay reachable until here.
|
||||
img.load()
|
||||
if img.mode not in ("RGB", "RGBA"):
|
||||
img = img.convert("RGB")
|
||||
return img
|
||||
|
||||
|
||||
def safe_close(img: Image.Image | None) -> None:
|
||||
"""Close a PIL image without raising. No-op on None."""
|
||||
if img is None:
|
||||
return
|
||||
try:
|
||||
img.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def is_portrait_img(img: Image.Image) -> bool:
|
||||
try:
|
||||
w, h = img.size
|
||||
return h >= w
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def is_portrait_item(item: MediaItem, img: Image.Image | None = None) -> bool:
|
||||
by_meta = _is_portrait_dims(item.width, item.height)
|
||||
if by_meta is not None:
|
||||
return by_meta
|
||||
if img is not None:
|
||||
return is_portrait_img(img)
|
||||
return False
|
||||
|
||||
|
||||
def is_portrait_item_by_metadata(item: MediaItem) -> bool | None:
|
||||
"""Return portrait/landscape from item metadata only, or None if unknown."""
|
||||
return _is_portrait_dims(item.width, item.height)
|
||||
|
||||
|
||||
def resolve_output_size(
|
||||
req_w: int | None,
|
||||
req_h: int | None,
|
||||
ratio: str,
|
||||
max_short_edge: int | None = None,
|
||||
) -> tuple[int, int]:
|
||||
ratio_w, ratio_h = _parse_aspect_ratio(ratio)
|
||||
target = ratio_w / ratio_h
|
||||
|
||||
if req_w is None and req_h is None:
|
||||
if ratio_w >= ratio_h:
|
||||
width = 3840
|
||||
height = max(1, int(round(width / target)))
|
||||
else:
|
||||
height = 3840
|
||||
width = max(1, int(round(height * target)))
|
||||
elif req_w is None:
|
||||
height = max(1, int(req_h or 2160))
|
||||
width = max(1, int(round(height * target)))
|
||||
elif req_h is None:
|
||||
width = max(1, int(req_w or 3840))
|
||||
height = max(1, int(round(width / target)))
|
||||
else:
|
||||
req_w = max(1, int(req_w))
|
||||
req_h = max(1, int(req_h))
|
||||
if (req_w / req_h) >= target:
|
||||
height = req_h
|
||||
width = max(1, int(round(height * target)))
|
||||
else:
|
||||
width = req_w
|
||||
height = max(1, int(round(width / target)))
|
||||
|
||||
if max_short_edge is not None:
|
||||
short = min(width, height)
|
||||
if short > max_short_edge:
|
||||
scale = max_short_edge / short
|
||||
width = max(1, int(round(width * scale)))
|
||||
height = max(1, int(round(height * scale)))
|
||||
|
||||
return (width, height)
|
||||
|
||||
|
||||
def render_image(img: Image.Image, fill_mode: str, width: int, height: int) -> Image.Image:
|
||||
"""Render img into a (width x height) canvas using the given fill mode."""
|
||||
if fill_mode == FILL_CONTAIN:
|
||||
return _resize_contain(img, width, height)
|
||||
if fill_mode == FILL_BLUR:
|
||||
return _blur_fill(img, width, height)
|
||||
return _resize_cover(img, width, height)
|
||||
|
||||
|
||||
def pair_images(
|
||||
img1: Image.Image,
|
||||
img2: Image.Image,
|
||||
target_w: int,
|
||||
target_h: int,
|
||||
fill_mode: str,
|
||||
portrait_canvas: bool,
|
||||
divider: int,
|
||||
divider_fill: tuple[int, int, int] | tuple[int, int, int, int],
|
||||
transparent_divider: bool,
|
||||
) -> Image.Image:
|
||||
canvas_mode = "RGBA" if transparent_divider else "RGB"
|
||||
canvas = Image.new(canvas_mode, (target_w, target_h), divider_fill)
|
||||
|
||||
if portrait_canvas:
|
||||
top_h = max(1, (target_h - divider) // 2)
|
||||
bottom_h = max(1, target_h - divider - top_h)
|
||||
top_img = render_image(img1, fill_mode, target_w, top_h)
|
||||
bottom_img = render_image(img2, fill_mode, target_w, bottom_h)
|
||||
canvas.paste(top_img.convert(canvas_mode), (0, 0))
|
||||
canvas.paste(bottom_img.convert(canvas_mode), (0, top_h + divider))
|
||||
safe_close(top_img)
|
||||
safe_close(bottom_img)
|
||||
return canvas
|
||||
|
||||
left_w = max(1, (target_w - divider) // 2)
|
||||
right_w = max(1, target_w - divider - left_w)
|
||||
left_img = render_image(img1, fill_mode, left_w, target_h)
|
||||
right_img = render_image(img2, fill_mode, right_w, target_h)
|
||||
canvas.paste(left_img.convert(canvas_mode), (0, 0))
|
||||
canvas.paste(right_img.convert(canvas_mode), (left_w + divider, 0))
|
||||
safe_close(left_img)
|
||||
safe_close(right_img)
|
||||
return canvas
|
||||
|
||||
|
||||
def encode_image(img: Image.Image) -> bytes:
|
||||
"""Encode a PIL image to a client-compatible JPEG or PNG.
|
||||
|
||||
JPEGs are written as baseline (non-progressive) with 4:2:0 subsampling and
|
||||
without EXIF, which maximises compatibility with Android WebView and older
|
||||
clients. RGBA images are encoded as PNG to preserve alpha.
|
||||
"""
|
||||
out = io.BytesIO()
|
||||
if "A" in img.getbands():
|
||||
img.save(out, format="PNG", optimize=True)
|
||||
return out.getvalue()
|
||||
rgb = img if img.mode == "RGB" else img.convert("RGB")
|
||||
rgb.save(
|
||||
out,
|
||||
format="JPEG",
|
||||
quality=88,
|
||||
optimize=True,
|
||||
progressive=False,
|
||||
subsampling=2,
|
||||
)
|
||||
if rgb is not img:
|
||||
safe_close(rgb)
|
||||
return out.getvalue()
|
||||
|
||||
|
||||
def parse_divider_color(color: str) -> tuple[tuple[int, int, int] | tuple[int, int, int, int], bool]:
|
||||
raw = (color or "").strip().lower()
|
||||
compact = raw.replace(" ", "")
|
||||
if compact in ("transparent", "transperant", "none", "clear", "rgba(0,0,0,0)"):
|
||||
return (0, 0, 0, 0), True
|
||||
try:
|
||||
return ImageColor.getrgb(color), False
|
||||
except Exception:
|
||||
return (255, 255, 255), False
|
||||
|
||||
|
||||
# -- Private helpers ---------------------------------------------------------
|
||||
|
||||
def _is_portrait_dims(width: int | None, height: int | None) -> bool | None:
|
||||
if not width or not height:
|
||||
return None
|
||||
try:
|
||||
w, h = int(width), int(height)
|
||||
if w <= 0 or h <= 0:
|
||||
return None
|
||||
return h >= w
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _parse_aspect_ratio(ratio: str) -> tuple[int, int]:
|
||||
try:
|
||||
left, right = ratio.split(":", maxsplit=1)
|
||||
w, h = int(left), int(right)
|
||||
if w > 0 and h > 0:
|
||||
return (w, h)
|
||||
except Exception:
|
||||
pass
|
||||
return (16, 9)
|
||||
|
||||
|
||||
def _resize_cover(img: Image.Image, target_w: int, target_h: int) -> Image.Image:
|
||||
src_w, src_h = img.size
|
||||
if src_w <= 0 or src_h <= 0:
|
||||
return img.resize((target_w, target_h))
|
||||
scale = max(target_w / src_w, target_h / src_h)
|
||||
new_w = max(1, int(round(src_w * scale)))
|
||||
new_h = max(1, int(round(src_h * scale)))
|
||||
resized = img.resize((new_w, new_h), Image.Resampling.LANCZOS)
|
||||
left = max(0, int(round((new_w - target_w) / 2)))
|
||||
top = max(0, int(round((new_h - target_h) / 2)))
|
||||
cropped = resized.crop((left, top, left + target_w, top + target_h))
|
||||
if cropped is not resized:
|
||||
safe_close(resized)
|
||||
return cropped
|
||||
|
||||
|
||||
def _resize_contain(img: Image.Image, target_w: int, target_h: int, bg=(0, 0, 0)) -> Image.Image:
|
||||
src_w, src_h = img.size
|
||||
if src_w <= 0 or src_h <= 0:
|
||||
return img.resize((target_w, target_h))
|
||||
scale = min(target_w / src_w, target_h / src_h)
|
||||
new_w = max(1, int(src_w * scale))
|
||||
new_h = max(1, int(src_h * scale))
|
||||
resized = img.resize((new_w, new_h), Image.Resampling.LANCZOS)
|
||||
canvas = Image.new("RGB", (target_w, target_h), bg)
|
||||
rgb_resized = resized if resized.mode == "RGB" else resized.convert("RGB")
|
||||
canvas.paste(rgb_resized, ((target_w - new_w) // 2, (target_h - new_h) // 2))
|
||||
if rgb_resized is not resized:
|
||||
safe_close(rgb_resized)
|
||||
safe_close(resized)
|
||||
return canvas
|
||||
|
||||
|
||||
def _blur_fill(img: Image.Image, target_w: int, target_h: int) -> Image.Image:
|
||||
bg = _resize_cover(img, target_w, target_h).filter(ImageFilter.GaussianBlur(radius=24))
|
||||
src_w, src_h = img.size
|
||||
if src_w <= 0 or src_h <= 0:
|
||||
return bg
|
||||
scale = min(target_w / src_w, target_h / src_h)
|
||||
new_w = max(1, int(src_w * scale))
|
||||
new_h = max(1, int(src_h * scale))
|
||||
fg = img.resize((new_w, new_h), Image.Resampling.LANCZOS)
|
||||
rgb_fg = fg if fg.mode == "RGB" else fg.convert("RGB")
|
||||
bg.paste(rgb_fg, ((target_w - new_w) // 2, (target_h - new_h) // 2))
|
||||
if rgb_fg is not fg:
|
||||
safe_close(rgb_fg)
|
||||
safe_close(fg)
|
||||
return bg
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"domain": "album_slideshow",
|
||||
"name": "Album Slideshow Camera",
|
||||
"codeowners": ["@eyalgal"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["http", "frontend"],
|
||||
"documentation": "https://github.com/eyalgal/album_slideshow",
|
||||
"iot_class": "cloud_polling",
|
||||
"issue_tracker": "https://github.com/eyalgal/album_slideshow/issues",
|
||||
"requirements": ["Pillow"],
|
||||
"version": "1.0.0"
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.number import NumberEntity, NumberMode
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import AlbumCoordinator
|
||||
from .store import SlideshowStore
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback) -> None:
|
||||
store: SlideshowStore = hass.data[DOMAIN][entry.entry_id]["store"]
|
||||
coordinator: AlbumCoordinator = hass.data[DOMAIN][entry.entry_id]["coordinator"]
|
||||
|
||||
async_add_entities(
|
||||
[
|
||||
SlideIntervalNumber(entry, store),
|
||||
RefreshHoursNumber(entry, store, coordinator),
|
||||
PairDividerWidthNumber(entry, store),
|
||||
ImageCacheMbNumber(entry, store),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class _BaseNumber(NumberEntity, RestoreEntity):
|
||||
_attr_has_entity_name = True
|
||||
_attr_should_poll = False
|
||||
_attr_mode = NumberMode.BOX
|
||||
|
||||
def __init__(self, entry: ConfigEntry, store: SlideshowStore) -> None:
|
||||
self.entry = entry
|
||||
self.store = store
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
await super().async_added_to_hass()
|
||||
self.store.add_listener(self.async_write_ha_state)
|
||||
|
||||
@property
|
||||
def device_info(self):
|
||||
return {
|
||||
"identifiers": {(DOMAIN, self.entry.entry_id)},
|
||||
"name": f"Album Slideshow {self.entry.title}",
|
||||
"manufacturer": "Album Slideshow",
|
||||
}
|
||||
|
||||
|
||||
class SlideIntervalNumber(_BaseNumber):
|
||||
_attr_icon = "mdi:timer-outline"
|
||||
_attr_native_min_value = 3
|
||||
_attr_native_max_value = 3600
|
||||
_attr_native_step = 1
|
||||
|
||||
def __init__(self, entry: ConfigEntry, store: SlideshowStore) -> None:
|
||||
super().__init__(entry, store)
|
||||
self._attr_unique_id = f"{entry.entry_id}_interval"
|
||||
self._attr_name = "Slide interval (seconds)"
|
||||
|
||||
@property
|
||||
def native_value(self):
|
||||
return int(self.store.slide_interval)
|
||||
|
||||
async def async_set_native_value(self, value: float) -> None:
|
||||
self.store.slide_interval = int(value)
|
||||
self.store.notify()
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
await super().async_added_to_hass()
|
||||
old = await self.async_get_last_state()
|
||||
if old and old.state not in (None, "unknown", "unavailable"):
|
||||
try:
|
||||
self.store.slide_interval = int(float(old.state))
|
||||
self.store.notify()
|
||||
except Exception:
|
||||
return
|
||||
|
||||
|
||||
class RefreshHoursNumber(_BaseNumber):
|
||||
_attr_icon = "mdi:refresh"
|
||||
_attr_native_min_value = 1
|
||||
_attr_native_max_value = 168
|
||||
_attr_native_step = 1
|
||||
|
||||
def __init__(self, entry: ConfigEntry, store: SlideshowStore, coordinator: AlbumCoordinator) -> None:
|
||||
super().__init__(entry, store)
|
||||
self.coordinator = coordinator
|
||||
self._attr_unique_id = f"{entry.entry_id}_refresh_minutes"
|
||||
self._attr_name = "Album refresh (hours)"
|
||||
|
||||
@property
|
||||
def native_value(self):
|
||||
return int(self.store.refresh_hours)
|
||||
|
||||
async def async_set_native_value(self, value: float) -> None:
|
||||
self.store.refresh_hours = int(value)
|
||||
self.store.notify()
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
await super().async_added_to_hass()
|
||||
old = await self.async_get_last_state()
|
||||
if old and old.state not in (None, "unknown", "unavailable"):
|
||||
try:
|
||||
val = int(float(old.state))
|
||||
# Migration: old values were in minutes; convert if above max hours
|
||||
if val > self._attr_native_max_value:
|
||||
val = max(int(val / 60), int(self._attr_native_min_value))
|
||||
self.store.refresh_hours = val
|
||||
self.store.notify()
|
||||
except Exception:
|
||||
return
|
||||
|
||||
|
||||
class PairDividerWidthNumber(_BaseNumber):
|
||||
_attr_icon = "mdi:border-vertical"
|
||||
_attr_native_min_value = 0
|
||||
_attr_native_max_value = 64
|
||||
_attr_native_step = 1
|
||||
|
||||
def __init__(self, entry: ConfigEntry, store: SlideshowStore) -> None:
|
||||
super().__init__(entry, store)
|
||||
self._attr_unique_id = f"{entry.entry_id}_pair_divider_px"
|
||||
self._attr_name = "Pair divider size (px)"
|
||||
|
||||
@property
|
||||
def native_value(self):
|
||||
return int(self.store.pair_divider_px)
|
||||
|
||||
async def async_set_native_value(self, value: float) -> None:
|
||||
self.store.pair_divider_px = max(0, int(value))
|
||||
self.store.notify()
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
await super().async_added_to_hass()
|
||||
old = await self.async_get_last_state()
|
||||
if old and old.state not in (None, "unknown", "unavailable"):
|
||||
try:
|
||||
self.store.pair_divider_px = max(0, int(float(old.state)))
|
||||
self.store.notify()
|
||||
except Exception:
|
||||
return
|
||||
|
||||
|
||||
class ImageCacheMbNumber(_BaseNumber):
|
||||
_attr_icon = "mdi:database-outline"
|
||||
_attr_native_min_value = 50
|
||||
_attr_native_max_value = 1000
|
||||
_attr_native_step = 50
|
||||
_attr_native_unit_of_measurement = "MB"
|
||||
|
||||
def __init__(self, entry: ConfigEntry, store: SlideshowStore) -> None:
|
||||
super().__init__(entry, store)
|
||||
self._attr_unique_id = f"{entry.entry_id}_image_cache_mb"
|
||||
self._attr_name = "Image cache size (MB)"
|
||||
|
||||
@property
|
||||
def native_value(self):
|
||||
return int(self.store.image_cache_mb)
|
||||
|
||||
async def async_set_native_value(self, value: float) -> None:
|
||||
self.store.image_cache_mb = max(50, int(value))
|
||||
self.store.notify()
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
await super().async_added_to_hass()
|
||||
old = await self.async_get_last_state()
|
||||
if old and old.state not in (None, "unknown", "unavailable"):
|
||||
try:
|
||||
self.store.image_cache_mb = max(50, int(float(old.state)))
|
||||
self.store.notify()
|
||||
except Exception:
|
||||
return
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Playlist construction: ordering and date filtering.
|
||||
|
||||
Pure, dependency-free helpers so the camera and tests can share a single
|
||||
implementation. Operates on ``MediaItem``-like objects that expose
|
||||
``captured_at`` / ``uploaded_at`` (epoch ms or ``None``).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Iterable, TypeVar
|
||||
|
||||
from .const import (
|
||||
DATE_FILTER_LAST_7,
|
||||
DATE_FILTER_LAST_30,
|
||||
DATE_FILTER_LAST_365,
|
||||
DATE_FILTER_OFF,
|
||||
DATE_FILTER_ON_THIS_DAY,
|
||||
DATE_FILTER_THIS_MONTH,
|
||||
DATE_FILTER_THIS_YEAR,
|
||||
ORDER_ALBUM,
|
||||
ORDER_NEWEST_ADDED,
|
||||
ORDER_NEWEST_TAKEN,
|
||||
ORDER_OLDEST_ADDED,
|
||||
ORDER_OLDEST_TAKEN,
|
||||
ORDER_RANDOM,
|
||||
)
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
def order_items(items: list[T], order_mode: str) -> list[T]:
|
||||
"""Return a new list ordered per ``order_mode``.
|
||||
|
||||
``random`` and ``album_order`` are no-ops here - random shuffling lives
|
||||
in the camera so it can dedupe recent slides; ``album_order`` keeps the
|
||||
source order untouched. The taken/added orderings are stable; items
|
||||
without the required timestamp keep their relative position at the end.
|
||||
"""
|
||||
if order_mode == ORDER_RANDOM or order_mode == ORDER_ALBUM:
|
||||
return list(items)
|
||||
|
||||
key_attr, reverse = _order_key(order_mode)
|
||||
if key_attr is None:
|
||||
return list(items)
|
||||
|
||||
with_ts: list[tuple[int, int, T]] = []
|
||||
without_ts: list[tuple[int, T]] = []
|
||||
for idx, it in enumerate(items):
|
||||
ts = getattr(it, key_attr, None)
|
||||
if isinstance(ts, int):
|
||||
with_ts.append((ts, idx, it))
|
||||
else:
|
||||
without_ts.append((idx, it))
|
||||
|
||||
with_ts.sort(key=lambda t: (t[0], t[1]), reverse=reverse)
|
||||
return [it for _, _, it in with_ts] + [it for _, it in without_ts]
|
||||
|
||||
|
||||
def _order_key(order_mode: str) -> tuple[str | None, bool]:
|
||||
if order_mode == ORDER_NEWEST_TAKEN:
|
||||
return "captured_at", True
|
||||
if order_mode == ORDER_OLDEST_TAKEN:
|
||||
return "captured_at", False
|
||||
if order_mode == ORDER_NEWEST_ADDED:
|
||||
return "uploaded_at", True
|
||||
if order_mode == ORDER_OLDEST_ADDED:
|
||||
return "uploaded_at", False
|
||||
return None, False
|
||||
|
||||
|
||||
def filter_items(
|
||||
items: Iterable[T],
|
||||
*,
|
||||
mode: str,
|
||||
now: datetime | None = None,
|
||||
) -> list[T]:
|
||||
"""Filter items by ``captured_at`` according to ``mode``.
|
||||
|
||||
Items with no ``captured_at`` are kept by default unless the mode is
|
||||
``on_this_day`` (treated as a strict filter).
|
||||
|
||||
``now`` is overridable for deterministic tests.
|
||||
"""
|
||||
if not mode or mode == DATE_FILTER_OFF:
|
||||
return list(items)
|
||||
|
||||
today_utc = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
|
||||
|
||||
pred, strict = _build_predicate(mode, today_utc)
|
||||
if pred is None:
|
||||
return list(items)
|
||||
|
||||
out: list[T] = []
|
||||
for it in items:
|
||||
ts = getattr(it, "captured_at", None)
|
||||
if not isinstance(ts, int):
|
||||
if not strict:
|
||||
out.append(it)
|
||||
continue
|
||||
if pred(ts):
|
||||
out.append(it)
|
||||
return out
|
||||
|
||||
|
||||
def _build_predicate(
|
||||
mode: str,
|
||||
today_utc: datetime,
|
||||
):
|
||||
"""Return (predicate, strict). ``strict`` drops items without timestamps."""
|
||||
if mode == DATE_FILTER_LAST_7:
|
||||
cutoff = int((today_utc - timedelta(days=7)).timestamp() * 1000)
|
||||
return (lambda ts: ts >= cutoff), False
|
||||
if mode == DATE_FILTER_LAST_30:
|
||||
cutoff = int((today_utc - timedelta(days=30)).timestamp() * 1000)
|
||||
return (lambda ts: ts >= cutoff), False
|
||||
if mode == DATE_FILTER_LAST_365:
|
||||
cutoff = int((today_utc - timedelta(days=365)).timestamp() * 1000)
|
||||
return (lambda ts: ts >= cutoff), False
|
||||
if mode == DATE_FILTER_THIS_MONTH:
|
||||
start = today_utc.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
cutoff = int(start.timestamp() * 1000)
|
||||
return (lambda ts: ts >= cutoff), False
|
||||
if mode == DATE_FILTER_THIS_YEAR:
|
||||
start = today_utc.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
cutoff = int(start.timestamp() * 1000)
|
||||
return (lambda ts: ts >= cutoff), False
|
||||
if mode == DATE_FILTER_ON_THIS_DAY:
|
||||
# Match items whose UTC month+day equals today's. Useful for daily
|
||||
# "memories"-style rotation across all years.
|
||||
today_md = (today_utc.month, today_utc.day)
|
||||
|
||||
def _on_this_day(ts: int) -> bool:
|
||||
d = datetime.fromtimestamp(ts / 1000, tz=timezone.utc)
|
||||
return (d.month, d.day) == today_md
|
||||
|
||||
return _on_this_day, True
|
||||
|
||||
return None, False
|
||||
@@ -0,0 +1,241 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.select import SelectEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
FILL_COVER,
|
||||
FILL_CONTAIN,
|
||||
FILL_BLUR,
|
||||
MAX_RESOLUTION_OPTIONS,
|
||||
ORIENTATION_MISMATCH_PAIR,
|
||||
ORIENTATION_MISMATCH_SINGLE,
|
||||
ORIENTATION_MISMATCH_AVOID,
|
||||
ORDER_OPTIONS,
|
||||
ORDER_RANDOM,
|
||||
DATE_FILTER_OPTIONS,
|
||||
DATE_FILTER_OFF,
|
||||
)
|
||||
from .store import SlideshowStore
|
||||
|
||||
ASPECT_RATIO_OPTIONS = ["16:9", "16:10", "4:3", "1:1", "3:4", "10:16", "9:16"]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
store: SlideshowStore = hass.data[DOMAIN][entry.entry_id]["store"]
|
||||
async_add_entities(
|
||||
[
|
||||
FillModeSelect(entry, store),
|
||||
PortraitModeSelect(entry, store),
|
||||
OrderModeSelect(entry, store),
|
||||
AspectRatioSelect(entry, store),
|
||||
MaxResolutionSelect(entry, store),
|
||||
DateFilterSelect(entry, store),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class _BaseSelect(SelectEntity, RestoreEntity):
|
||||
_attr_has_entity_name = True
|
||||
_attr_should_poll = False
|
||||
_attr_entity_registry_enabled_default = True
|
||||
|
||||
def __init__(self, entry: ConfigEntry, store: SlideshowStore) -> None:
|
||||
self.entry = entry
|
||||
self.store = store
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
await super().async_added_to_hass()
|
||||
self.store.add_listener(self.async_write_ha_state)
|
||||
|
||||
@property
|
||||
def device_info(self):
|
||||
return {
|
||||
"identifiers": {(DOMAIN, self.entry.entry_id)},
|
||||
"name": f"Album Slideshow {self.entry.title}",
|
||||
"manufacturer": "Album Slideshow",
|
||||
}
|
||||
|
||||
|
||||
class FillModeSelect(_BaseSelect):
|
||||
_attr_icon = "mdi:aspect-ratio"
|
||||
|
||||
def __init__(self, entry: ConfigEntry, store: SlideshowStore) -> None:
|
||||
super().__init__(entry, store)
|
||||
self._attr_unique_id = f"{entry.entry_id}_fill_mode"
|
||||
self._attr_name = "Fill mode"
|
||||
self._attr_options = [FILL_BLUR, FILL_COVER, FILL_CONTAIN]
|
||||
|
||||
@property
|
||||
def current_option(self):
|
||||
value = self.store.fill_mode
|
||||
return value if value in self.options else self.options[0]
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
if option not in self.options:
|
||||
return
|
||||
self.store.fill_mode = option
|
||||
self.store.notify()
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
await super().async_added_to_hass()
|
||||
old = await self.async_get_last_state()
|
||||
if old and old.state in self.options:
|
||||
self.store.fill_mode = old.state
|
||||
self.store.notify()
|
||||
|
||||
|
||||
class PortraitModeSelect(_BaseSelect):
|
||||
_attr_icon = "mdi:account-box-outline"
|
||||
|
||||
def __init__(self, entry: ConfigEntry, store: SlideshowStore) -> None:
|
||||
super().__init__(entry, store)
|
||||
self._attr_unique_id = f"{entry.entry_id}_portrait_mode"
|
||||
self._attr_name = "Orientation mismatch mode"
|
||||
self._attr_options = [
|
||||
ORIENTATION_MISMATCH_PAIR,
|
||||
ORIENTATION_MISMATCH_SINGLE,
|
||||
ORIENTATION_MISMATCH_AVOID,
|
||||
]
|
||||
|
||||
@property
|
||||
def current_option(self):
|
||||
value = self.store.portrait_mode
|
||||
return value if value in self.options else self.options[0]
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
if option not in self.options:
|
||||
return
|
||||
self.store.portrait_mode = option
|
||||
self.store.notify()
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
await super().async_added_to_hass()
|
||||
old = await self.async_get_last_state()
|
||||
if old:
|
||||
restored = old.state
|
||||
if restored in ("blur", "crop"):
|
||||
restored = ORIENTATION_MISMATCH_SINGLE
|
||||
if restored in self.options:
|
||||
self.store.portrait_mode = restored
|
||||
self.store.notify()
|
||||
|
||||
|
||||
class OrderModeSelect(_BaseSelect):
|
||||
_attr_icon = "mdi:shuffle-variant"
|
||||
|
||||
def __init__(self, entry: ConfigEntry, store: SlideshowStore) -> None:
|
||||
super().__init__(entry, store)
|
||||
self._attr_unique_id = f"{entry.entry_id}_order_mode"
|
||||
self._attr_name = "Order mode"
|
||||
self._attr_options = list(ORDER_OPTIONS)
|
||||
|
||||
@property
|
||||
def current_option(self):
|
||||
value = self.store.order_mode
|
||||
return value if value in self.options else ORDER_RANDOM
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
if option not in self.options:
|
||||
return
|
||||
self.store.order_mode = option
|
||||
self.store.notify()
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
await super().async_added_to_hass()
|
||||
old = await self.async_get_last_state()
|
||||
if old and old.state in self.options:
|
||||
self.store.order_mode = old.state
|
||||
self.store.notify()
|
||||
|
||||
|
||||
class AspectRatioSelect(_BaseSelect):
|
||||
_attr_icon = "mdi:crop"
|
||||
|
||||
def __init__(self, entry: ConfigEntry, store: SlideshowStore) -> None:
|
||||
super().__init__(entry, store)
|
||||
self._attr_unique_id = f"{entry.entry_id}_aspect_ratio"
|
||||
self._attr_name = "Aspect ratio"
|
||||
self._attr_options = ASPECT_RATIO_OPTIONS
|
||||
|
||||
@property
|
||||
def current_option(self):
|
||||
value = self.store.aspect_ratio
|
||||
return value if value in self.options else self.options[0]
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
if option not in self.options:
|
||||
return
|
||||
self.store.aspect_ratio = option
|
||||
self.store.notify()
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
await super().async_added_to_hass()
|
||||
old = await self.async_get_last_state()
|
||||
if old and old.state in self.options:
|
||||
self.store.aspect_ratio = old.state
|
||||
self.store.notify()
|
||||
|
||||
|
||||
class MaxResolutionSelect(_BaseSelect):
|
||||
_attr_icon = "mdi:image-size-select-large"
|
||||
|
||||
def __init__(self, entry: ConfigEntry, store: SlideshowStore) -> None:
|
||||
super().__init__(entry, store)
|
||||
self._attr_unique_id = f"{entry.entry_id}_max_resolution"
|
||||
self._attr_name = "Max resolution"
|
||||
self._attr_options = MAX_RESOLUTION_OPTIONS
|
||||
|
||||
@property
|
||||
def current_option(self):
|
||||
value = self.store.max_resolution
|
||||
return value if value in self.options else self.options[0]
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
if option not in self.options:
|
||||
return
|
||||
self.store.max_resolution = option
|
||||
self.store.notify()
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
await super().async_added_to_hass()
|
||||
old = await self.async_get_last_state()
|
||||
if old and old.state in self.options:
|
||||
self.store.max_resolution = old.state
|
||||
self.store.notify()
|
||||
|
||||
|
||||
class DateFilterSelect(_BaseSelect):
|
||||
_attr_icon = "mdi:calendar-filter"
|
||||
|
||||
def __init__(self, entry: ConfigEntry, store: SlideshowStore) -> None:
|
||||
super().__init__(entry, store)
|
||||
self._attr_unique_id = f"{entry.entry_id}_date_filter"
|
||||
self._attr_name = "Date filter"
|
||||
self._attr_options = list(DATE_FILTER_OPTIONS)
|
||||
|
||||
@property
|
||||
def current_option(self):
|
||||
value = self.store.date_filter
|
||||
return value if value in self.options else DATE_FILTER_OFF
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
if option not in self.options:
|
||||
return
|
||||
self.store.date_filter = option
|
||||
self.store.notify()
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
await super().async_added_to_hass()
|
||||
old = await self.async_get_last_state()
|
||||
if old and old.state in self.options:
|
||||
self.store.date_filter = old.state
|
||||
self.store.notify()
|
||||
@@ -0,0 +1,150 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.sensor import SensorEntity, SensorStateClass
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity import EntityCategory
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .const import DOMAIN, PROVIDER_GOOGLE_SHARED, PROVIDER_LOCAL_FOLDER
|
||||
from .coordinator import AlbumCoordinator
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback) -> None:
|
||||
coordinator: AlbumCoordinator = hass.data[DOMAIN][entry.entry_id]["coordinator"]
|
||||
entities: list[SensorEntity] = [
|
||||
AlbumCountSensor(entry, coordinator),
|
||||
AlbumTitleSensor(entry, coordinator),
|
||||
CacheUsageSensor(entry, coordinator),
|
||||
]
|
||||
if coordinator.provider == PROVIDER_LOCAL_FOLDER:
|
||||
# Diagnostic surface for the local-folder background enrichment
|
||||
# (EXIF reads + reverse-geocode). For Google albums there's no
|
||||
# enrichment work, so this sensor is omitted to keep the device
|
||||
# screen tidy.
|
||||
entities.append(EnrichmentProgressSensor(entry, coordinator))
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class _BaseAlbumSensor(SensorEntity):
|
||||
_attr_should_poll = False
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(self, entry: ConfigEntry, coordinator: AlbumCoordinator) -> None:
|
||||
self.entry = entry
|
||||
self.coordinator = coordinator
|
||||
coordinator.async_add_listener(self.async_write_ha_state)
|
||||
|
||||
@property
|
||||
def device_info(self):
|
||||
return {
|
||||
"identifiers": {(DOMAIN, self.entry.entry_id)},
|
||||
"name": f"Album Slideshow {self.entry.title}",
|
||||
"manufacturer": "Album Slideshow",
|
||||
}
|
||||
|
||||
def _provider_icon(self) -> str:
|
||||
if self.coordinator.provider == PROVIDER_GOOGLE_SHARED:
|
||||
return "mdi:google-photos"
|
||||
return "mdi:folder-multiple-image"
|
||||
|
||||
|
||||
class AlbumCountSensor(_BaseAlbumSensor):
|
||||
def __init__(self, entry: ConfigEntry, coordinator: AlbumCoordinator) -> None:
|
||||
super().__init__(entry, coordinator)
|
||||
self._attr_unique_id = f"{entry.entry_id}_count"
|
||||
self._attr_name = "Photo count"
|
||||
|
||||
@property
|
||||
def icon(self) -> str:
|
||||
return self._provider_icon()
|
||||
|
||||
@property
|
||||
def native_value(self):
|
||||
data = self.coordinator.data or {}
|
||||
return len(data.get("items", []))
|
||||
|
||||
|
||||
class AlbumTitleSensor(_BaseAlbumSensor):
|
||||
def __init__(self, entry: ConfigEntry, coordinator: AlbumCoordinator) -> None:
|
||||
super().__init__(entry, coordinator)
|
||||
self._attr_unique_id = f"{entry.entry_id}_title"
|
||||
self._attr_name = "Album title"
|
||||
|
||||
@property
|
||||
def icon(self) -> str:
|
||||
return self._provider_icon()
|
||||
|
||||
@property
|
||||
def native_value(self):
|
||||
data = self.coordinator.data or {}
|
||||
return data.get("title")
|
||||
|
||||
|
||||
class CacheUsageSensor(_BaseAlbumSensor):
|
||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||
_attr_native_unit_of_measurement = "MB"
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
_attr_icon = "mdi:database"
|
||||
_attr_should_poll = True
|
||||
|
||||
def __init__(self, entry: ConfigEntry, coordinator: AlbumCoordinator) -> None:
|
||||
super().__init__(entry, coordinator)
|
||||
self._attr_unique_id = f"{entry.entry_id}_cache_usage"
|
||||
self._attr_name = "Image cache usage"
|
||||
|
||||
@property
|
||||
def native_value(self):
|
||||
cam = self.hass.data.get(DOMAIN, {}).get(self.entry.entry_id, {}).get("camera")
|
||||
if cam is None:
|
||||
return None
|
||||
return cam.cache_usage_mb
|
||||
|
||||
|
||||
class EnrichmentProgressSensor(_BaseAlbumSensor):
|
||||
"""Percent-complete sensor for the local-folder enrichment worker.
|
||||
|
||||
Reports the slower-changing of the two phases:
|
||||
- ``exif``: reading capture date + GPS from EXIF tags.
|
||||
- ``geocoding``: reverse-geocoding GPS to a city/country label.
|
||||
|
||||
Holds at ``100`` when both phases finish and stays there until the
|
||||
next coordinator refresh discovers new files. The ``phase`` and
|
||||
raw counts are surfaced as state attributes so dashboards can show
|
||||
progress text alongside the bar.
|
||||
"""
|
||||
|
||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||
_attr_native_unit_of_measurement = "%"
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
_attr_icon = "mdi:map-marker-radius"
|
||||
|
||||
def __init__(self, entry: ConfigEntry, coordinator: AlbumCoordinator) -> None:
|
||||
super().__init__(entry, coordinator)
|
||||
self._attr_unique_id = f"{entry.entry_id}_enrichment_progress"
|
||||
self._attr_name = "Enrichment progress"
|
||||
|
||||
@property
|
||||
def native_value(self):
|
||||
prog = getattr(self.coordinator, "_enrich_progress", None) or {}
|
||||
phase = prog.get("phase")
|
||||
if phase == "geocoding":
|
||||
total = prog.get("geocode_total") or 0
|
||||
done = prog.get("geocode_done") or 0
|
||||
else:
|
||||
total = prog.get("exif_total") or 0
|
||||
done = prog.get("exif_done") or 0
|
||||
if total <= 0:
|
||||
return None
|
||||
return max(0, min(100, round(100 * done / total)))
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self):
|
||||
prog = getattr(self.coordinator, "_enrich_progress", None) or {}
|
||||
return {
|
||||
"phase": prog.get("phase"),
|
||||
"exif_total": prog.get("exif_total", 0),
|
||||
"exif_done": prog.get("exif_done", 0),
|
||||
"geocode_total": prog.get("geocode_total", 0),
|
||||
"geocode_done": prog.get("geocode_done", 0),
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
next_slide:
|
||||
name: Next slide
|
||||
description: Advance the slideshow to the next image for a specific config entry.
|
||||
fields:
|
||||
entry_id:
|
||||
name: Entry ID
|
||||
description: The config entry id for the album slideshow instance.
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
|
||||
refresh_album:
|
||||
name: Refresh album
|
||||
description: Refresh the album list for a specific config entry.
|
||||
fields:
|
||||
entry_id:
|
||||
name: Entry ID
|
||||
description: The config entry id for the album slideshow instance.
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
@@ -0,0 +1,55 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Callable
|
||||
|
||||
from .const import (
|
||||
DEFAULT_SLIDE_INTERVAL,
|
||||
DEFAULT_REFRESH_HOURS,
|
||||
DEFAULT_FILL_MODE,
|
||||
DEFAULT_ORIENTATION_MISMATCH_MODE,
|
||||
DEFAULT_ORDER_MODE,
|
||||
DEFAULT_ASPECT_RATIO,
|
||||
DEFAULT_PAIR_DIVIDER_PX,
|
||||
DEFAULT_PAIR_DIVIDER_COLOR,
|
||||
DEFAULT_IMAGE_CACHE_MB,
|
||||
DEFAULT_MAX_RESOLUTION,
|
||||
DEFAULT_DATE_FILTER,
|
||||
)
|
||||
|
||||
|
||||
Listener = Callable[[], None]
|
||||
|
||||
|
||||
@dataclass
|
||||
class SlideshowStore:
|
||||
slide_interval: int = DEFAULT_SLIDE_INTERVAL
|
||||
refresh_hours: int = DEFAULT_REFRESH_HOURS
|
||||
fill_mode: str = DEFAULT_FILL_MODE
|
||||
portrait_mode: str = DEFAULT_ORIENTATION_MISMATCH_MODE
|
||||
order_mode: str = DEFAULT_ORDER_MODE
|
||||
aspect_ratio: str = DEFAULT_ASPECT_RATIO
|
||||
pair_divider_px: int = DEFAULT_PAIR_DIVIDER_PX
|
||||
pair_divider_color: str = DEFAULT_PAIR_DIVIDER_COLOR
|
||||
image_cache_mb: int = DEFAULT_IMAGE_CACHE_MB
|
||||
max_resolution: str = DEFAULT_MAX_RESOLUTION
|
||||
|
||||
# Date filter mode (preset windows like this_year / on_this_day).
|
||||
date_filter: str = DEFAULT_DATE_FILTER
|
||||
|
||||
# Pause toggle - when True, the slideshow holds on the current frame.
|
||||
paused: bool = False
|
||||
|
||||
# In-memory last rendered frame. Not user-configurable; used to re-serve
|
||||
# the previous slide instantly across a camera reload.
|
||||
last_frame: bytes | None = None
|
||||
|
||||
_listeners: list[Listener] = field(default_factory=list)
|
||||
|
||||
def add_listener(self, cb: Listener) -> None:
|
||||
if cb not in self._listeners:
|
||||
self._listeners.append(cb)
|
||||
|
||||
def notify(self) -> None:
|
||||
for cb in list(self._listeners):
|
||||
cb()
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Add album slideshow",
|
||||
"description": "Choose a source for the slideshow.",
|
||||
"data": {
|
||||
"provider": "Source"
|
||||
}
|
||||
},
|
||||
"google_shared": {
|
||||
"title": "Google Photos",
|
||||
"description": "Paste a Google Photos shared album share link.",
|
||||
"data": {
|
||||
"album_name": "Album name",
|
||||
"album_url": "Shared album link"
|
||||
}
|
||||
},
|
||||
"local_folder": {
|
||||
"title": "Local Folder",
|
||||
"description": "Pick a folder path on your Home Assistant filesystem. Use /local/... (maps to /config/www/...) or /media/local/... for NAS-mounted media folders. See the README for details.",
|
||||
"data": {
|
||||
"album_name": "Album name",
|
||||
"local_path": "Folder path",
|
||||
"recursive": "Include subfolders"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"invalid_album_url": "That does not look like a Google Photos shared album link.",
|
||||
"invalid_path": "Path is empty or invalid."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Local Folder options",
|
||||
"description": "Reverse geocoding sends your photos' EXIF GPS coordinates to the public OpenStreetMap Nominatim service to look up a human-readable place name. Coordinates are rounded to ~100 m before lookup and cached on disk. Turn this off to keep coordinates entirely local; the latitude and longitude attributes still work either way.",
|
||||
"data": {
|
||||
"reverse_geocode": "Reverse-geocode EXIF GPS coordinates via OpenStreetMap"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.switch import SwitchEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .store import SlideshowStore
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback) -> None:
|
||||
store: SlideshowStore = hass.data[DOMAIN][entry.entry_id]["store"]
|
||||
async_add_entities([PauseSwitch(entry, store)])
|
||||
|
||||
|
||||
class PauseSwitch(SwitchEntity, RestoreEntity):
|
||||
_attr_has_entity_name = True
|
||||
_attr_should_poll = False
|
||||
_attr_icon = "mdi:pause"
|
||||
|
||||
def __init__(self, entry: ConfigEntry, store: SlideshowStore) -> None:
|
||||
self.entry = entry
|
||||
self.store = store
|
||||
self._attr_unique_id = f"{entry.entry_id}_paused"
|
||||
self._attr_name = "Pause slideshow"
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
return bool(self.store.paused)
|
||||
|
||||
@property
|
||||
def device_info(self):
|
||||
return {
|
||||
"identifiers": {(DOMAIN, self.entry.entry_id)},
|
||||
"name": f"Album Slideshow {self.entry.title}",
|
||||
"manufacturer": "Album Slideshow",
|
||||
}
|
||||
|
||||
async def async_turn_on(self, **kwargs) -> None:
|
||||
self.store.paused = True
|
||||
self.store.notify()
|
||||
|
||||
async def async_turn_off(self, **kwargs) -> None:
|
||||
self.store.paused = False
|
||||
self.store.notify()
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
await super().async_added_to_hass()
|
||||
self.store.add_listener(self.async_write_ha_state)
|
||||
old = await self.async_get_last_state()
|
||||
if old is not None and old.state in ("on", "off"):
|
||||
self.store.paused = old.state == "on"
|
||||
self.store.notify()
|
||||
@@ -0,0 +1,60 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.text import TextEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
|
||||
from .const import DOMAIN, DEFAULT_PAIR_DIVIDER_COLOR
|
||||
from .store import SlideshowStore
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback) -> None:
|
||||
store: SlideshowStore = hass.data[DOMAIN][entry.entry_id]["store"]
|
||||
async_add_entities([PairDividerColorText(entry, store)])
|
||||
|
||||
|
||||
class PairDividerColorText(TextEntity, RestoreEntity):
|
||||
_attr_has_entity_name = True
|
||||
_attr_should_poll = False
|
||||
_attr_icon = "mdi:palette"
|
||||
_attr_native_min = 1
|
||||
_attr_native_max = 32
|
||||
|
||||
def __init__(self, entry: ConfigEntry, store: SlideshowStore) -> None:
|
||||
self.entry = entry
|
||||
self.store = store
|
||||
|
||||
self._attr_unique_id = f"{entry.entry_id}_pair_divider_color"
|
||||
self._attr_name = "Pair divider color"
|
||||
|
||||
def _on_store_change() -> None:
|
||||
self.async_write_ha_state()
|
||||
|
||||
store.add_listener(_on_store_change)
|
||||
|
||||
@property
|
||||
def native_value(self) -> str:
|
||||
return self.store.pair_divider_color
|
||||
|
||||
@property
|
||||
def device_info(self):
|
||||
return {
|
||||
"identifiers": {(DOMAIN, self.entry.entry_id)},
|
||||
"name": f"Album Slideshow {self.entry.title}",
|
||||
"manufacturer": "Album Slideshow",
|
||||
}
|
||||
|
||||
async def async_set_value(self, value: str) -> None:
|
||||
val = (value or "").strip()
|
||||
self.store.pair_divider_color = val or DEFAULT_PAIR_DIVIDER_COLOR
|
||||
self.store.notify()
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
await super().async_added_to_hass()
|
||||
self.store.add_listener(self.async_write_ha_state)
|
||||
old = await self.async_get_last_state()
|
||||
if old and old.state not in (None, "unknown", "unavailable"):
|
||||
self.store.pair_divider_color = old.state.strip() or DEFAULT_PAIR_DIVIDER_COLOR
|
||||
self.store.notify()
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Add album slideshow",
|
||||
"description": "Choose a source for the slideshow.",
|
||||
"data": {
|
||||
"provider": "Source"
|
||||
}
|
||||
},
|
||||
"google_shared": {
|
||||
"title": "Google Photos",
|
||||
"description": "Paste a Google Photos shared album share link.",
|
||||
"data": {
|
||||
"album_name": "Album name",
|
||||
"album_url": "Shared album link"
|
||||
}
|
||||
},
|
||||
"local_folder": {
|
||||
"title": "Local Folder",
|
||||
"description": "Pick a folder path on your Home Assistant filesystem. Use /local/... (maps to /config/www/...) or /media/local/... for NAS-mounted media folders. See the README for details.",
|
||||
"data": {
|
||||
"album_name": "Album name",
|
||||
"local_path": "Folder path",
|
||||
"recursive": "Include subfolders"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"invalid_album_url": "That does not look like a Google Photos shared album link.",
|
||||
"invalid_path": "Path is empty or invalid."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Local Folder options",
|
||||
"description": "Reverse geocoding sends your photos' EXIF GPS coordinates to the public OpenStreetMap Nominatim service to look up a human-readable place name. Coordinates are rounded to ~100 m before lookup and cached on disk. Turn this off to keep coordinates entirely local; the latitude and longitude attributes still work either way.",
|
||||
"data": {
|
||||
"reverse_geocode": "Reverse-geocode EXIF GPS coordinates via OpenStreetMap"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user