feat: Add proxying support for images (#2427)

- Closes #2418
This commit is contained in:
Dermot Duffy
2026-06-30 17:45:12 -07:00
committed by dermotduffy
parent 9384785d37
commit 1cd5520154
51 changed files with 2404 additions and 687 deletions
+97 -47
View File
@@ -11,12 +11,13 @@ import { customElement, property, state } from 'lit/decorators.js';
import { live } from 'lit/directives/live.js';
import { createRef, ref, Ref } from 'lit/directives/ref.js';
import { isEqual } from 'lodash-es';
import { CameraManager } from '../camera-manager/manager.js';
import { getCameraEntityFromConfig } from '../camera-manager/utils/camera-entity-from-config.js';
import { CachedValueController } from '../components-lib/cached-value-controller.js';
import { UpdatingImageMediaPlayerController } from '../components-lib/media-player/updating-image.js';
import { SignedURLController } from '../components-lib/signed-url-controller.js';
import { CameraConfig } from '../config/schema/cameras.js';
import { type ImageBaseConfig, ImageMode } from '../config/schema/common/image.js';
import { EnabledProxyConfig } from '../config/schema/common/proxy.js';
import { isHassDifferent } from '../ha/is-hass-different.js';
import { HomeAssistant } from '../ha/types.js';
import defaultImage from '../images/iris-screensaver.jpg';
@@ -79,8 +80,8 @@ export class AdvancedCameraCardImageUpdatingPlayer
@property({ attribute: false })
public cameraConfig?: CameraConfig;
@property({ attribute: false })
public cameraManager?: CameraManager;
@property({ attribute: false, hasChanged: contentsChanged })
public proxyConfig?: EnabledProxyConfig;
// Using contentsChanged to ensure overridden configs (e.g. when the
// 'show_image_during_load' option is true for live views, an overridden
@@ -89,11 +90,36 @@ export class AdvancedCameraCardImageUpdatingPlayer
public imageConfig?: ImageBaseConfig;
@state()
private _message: Message | null = null;
private _imageLoadError = false;
private _refImage: Ref<HTMLImageElement> = createRef();
private _cachedValueController?: CachedValueController<string>;
private _cachedValueController = new CachedValueController(
this,
() => this.imageConfig?.refresh_seconds ?? null,
() => this._getImageSource(),
() => dispatchMediaPlayEvent(this),
() => dispatchMediaPauseEvent(this),
// Clear image load errors on each timer tick so the next render retries the
// <img>. Retries are bounded by refresh_seconds, not a tight loop.
() => {
this._imageLoadError = false;
},
);
private _signedURLController = new SignedURLController(
this,
() => ({
hass: this.hass,
endpoint: this.imageConfig?.url ? { endpoint: this.imageConfig.url } : undefined,
proxyConfig: this.proxyConfig,
}),
() => {
this._cachedValueController.clearValue();
this._imageLoadError = false;
},
);
private _boundVisibilityHandler = this._visibilityHandler.bind(this);
private _mediaLoadedInfo: MediaLoadedInfo | null = null;
@@ -101,7 +127,7 @@ export class AdvancedCameraCardImageUpdatingPlayer
private _mediaPlayerController = new UpdatingImageMediaPlayerController(
this,
() => this._refImage.value ?? null,
() => this._cachedValueController ?? null,
() => this._cachedValueController,
);
public async getMediaPlayerController(): Promise<MediaPlayerController | null> {
@@ -143,21 +169,6 @@ export class AdvancedCameraCardImageUpdatingPlayer
* @param _changedProps The changed properties
*/
protected willUpdate(changedProps: PropertyValues): void {
if (changedProps.has('imageConfig')) {
if (this._cachedValueController) {
this._cachedValueController.removeController();
}
if (this.imageConfig) {
this._cachedValueController = new CachedValueController(
this,
this.imageConfig.refresh_seconds,
this._getImageSource.bind(this),
() => dispatchMediaPlayEvent(this),
() => dispatchMediaPauseEvent(this),
);
}
}
const relevantEntity = this._getRelevantEntityForMode(
resolveImageMode({
imageConfig: this.imageConfig,
@@ -170,20 +181,19 @@ export class AdvancedCameraCardImageUpdatingPlayer
// the state is not acceptable, discard the old value (to allow a stock or
// backup image to be displayed).
if (
changedProps.has('imageConfig') ||
changedProps.has('cameraConfig') ||
changedProps.has('proxyConfig') ||
changedProps.has('view') ||
(relevantEntity && !this._getAcceptableState(relevantEntity))
) {
this._cachedValueController?.clearValue();
this._imageLoadError = false;
}
if (!this._cachedValueController?.value) {
if (!this._cachedValueController?.getValue()) {
this._cachedValueController?.updateValue();
}
if (['imageConfig', 'view'].some((prop) => changedProps.has(prop))) {
this._message = null;
}
}
/**
@@ -220,7 +230,7 @@ export class AdvancedCameraCardImageUpdatingPlayer
*/
disconnectedCallback(): void {
this._cachedValueController?.stopTimer();
this._message = null;
this._imageLoadError = false;
document.removeEventListener('visibilitychange', this._boundVisibilityHandler);
super.disconnectedCallback();
}
@@ -254,12 +264,23 @@ export class AdvancedCameraCardImageUpdatingPlayer
}
/**
* Build a working absolute image URL that the browser will not cache.
* @param url An input URL (may be relative to document origin)
* @returns A new URL as a string (absolute, will not be browser cached).
* Build an image URL that the browser will not cache. Supports two modes:
* - 'query-string': Appends a `_t` parameter. This is the most robust way to
* defeat caching (it bypasses HTTP caches) but it changes the path sent to
* the server and so can invalidate signed URLs.
* - 'fragment': Appends a `_t` fragment. This is less robust (the browser
* might still serve from its HTTP cache) but it does not change the URL
* sent to the server so it is safe for signed URLs.
* @param url The input URL.
* @param mode The cache-busting mode.
* @returns The cache-busted URL string.
*/
private _buildImageURL(url: URL): string {
url.searchParams.append('_t', String(Date.now()));
private _buildCacheBustURL(url: URL, mode: 'query-string' | 'fragment'): string {
if (mode === 'query-string') {
url.searchParams.append('_t', String(Date.now()));
} else {
url.hash = `_t=${Date.now()}`;
}
return url.toString();
}
@@ -294,7 +315,7 @@ export class AdvancedCameraCardImageUpdatingPlayer
if (state?.attributes.entity_picture) {
const urlObj = new URL(state.attributes.entity_picture, document.baseURI);
this._addQueryParametersToURL(urlObj, this.imageConfig?.entity_parameters);
return this._buildImageURL(urlObj);
return this._buildCacheBustURL(urlObj, 'query-string');
}
}
@@ -303,12 +324,21 @@ export class AdvancedCameraCardImageUpdatingPlayer
if (state?.attributes.entity_picture) {
const urlObj = new URL(state.attributes.entity_picture, document.baseURI);
this._addQueryParametersToURL(urlObj, this.imageConfig?.entity_parameters);
return this._buildImageURL(urlObj);
return this._buildCacheBustURL(urlObj, 'query-string');
}
}
if (mode === 'url' && this.imageConfig?.url) {
return this._buildImageURL(new URL(this.imageConfig.url, document.baseURI));
const url = this._signedURLController.getValue();
if (url) {
const urlObj = new URL(url, document.baseURI);
if (this.proxyConfig?.enabled) {
// Use a fragment for cache-busting proxied URLs, as this does not
// change the path and thus preserves the validity of the signed URL.
return this._buildCacheBustURL(urlObj, 'fragment');
}
return this._buildCacheBustURL(urlObj, 'query-string');
}
}
return defaultImage;
@@ -319,17 +349,43 @@ export class AdvancedCameraCardImageUpdatingPlayer
*/
private _forceSafeImage(stockOnly?: boolean): void {
if (this._refImage.value) {
this._refImage.value.src =
!stockOnly && this.imageConfig?.url ? this.imageConfig.url : defaultImage;
// Avoid restoring the raw configured URL when proxying is enabled, since
// that would bypass the proxied/signed URL path on visibility changes.
const configuredURL =
!stockOnly && !this.proxyConfig?.enabled ? this.imageConfig?.url ?? null : null;
this._refImage.value.src = configuredURL ?? defaultImage;
}
}
private _getDisplayMessage(): Message | null {
const error = this._signedURLController.getError();
if (error) {
return {
type: 'error',
message: localize(
error === 'proxy' ? 'error.failed_proxy' : 'error.failed_sign',
),
context: this.proxyConfig,
};
}
if (this._imageLoadError) {
return {
type: 'error',
message: localize('error.image_load_error'),
context: this.imageConfig,
};
}
return null;
}
protected render(): TemplateResult | void {
if (this._message) {
return renderMessage(this._message);
const message = this._getDisplayMessage();
if (message) {
return renderMessage(message);
}
const src = this._cachedValueController?.value;
const src = this._cachedValueController?.getValue();
// Note the use of live() below to ensure the update will restore the image
// src if it's been changed via _forceSafeImage().
return src
@@ -364,13 +420,7 @@ export class AdvancedCameraCardImageUpdatingPlayer
// failed to load.
this._forceSafeImage(true);
} else if (mode === 'url') {
// In url mode, the user likely specified a URL that cannot be
// resolved. Show an error message.
this._message = {
type: 'error',
message: localize('error.image_load_error'),
context: this.imageConfig,
};
this._imageLoadError = true;
}
}}
/>