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;
}
}}
/>
+16 -1
View File
@@ -7,7 +7,11 @@ import { ViewManagerEpoch } from '../card-controller/view/types';
import { ZoomSettingsObserved } from '../components-lib/zoom/types';
import { handleZoomSettingsObservedEvent } from '../components-lib/zoom/zoom-view-context';
import { CameraConfig } from '../config/schema/cameras';
import { ImageViewConfig } from '../config/schema/image';
import {
type EnabledProxyConfig,
resolveProxyConfig,
} from '../config/schema/common/proxy';
import { ImageViewConfig, type ImageViewProxyConfig } from '../config/schema/image';
import { IMAGE_VIEW_ZOOM_TARGET_SENTINEL } from '../const';
import { HomeAssistant } from '../ha/types';
import { localize } from '../localize/localize.js';
@@ -82,6 +86,16 @@ export class AdvancedCameraCardImage extends LitElement implements MediaPlayer {
: intermediateTemplate}`;
}
private _resolveProxyConfig(proxy?: ImageViewProxyConfig): EnabledProxyConfig | null {
return proxy
? {
...resolveProxyConfig(proxy),
enabled: proxy.enabled,
enforce: proxy.enabled,
}
: null;
}
protected render(): TemplateResult | void {
if (!this.hass) {
return;
@@ -108,6 +122,7 @@ export class AdvancedCameraCardImage extends LitElement implements MediaPlayer {
.view=${this.viewManagerEpoch?.manager.getView()}
.imageConfig=${this.imageConfig}
.cameraConfig=${this.cameraConfig}
.proxyConfig=${this._resolveProxyConfig(this.imageConfig?.proxy) ?? undefined}
>
</advanced-camera-card-image-updating-player>
`);
+1
View File
@@ -302,6 +302,7 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
${ref(this._refProvider)}
.hass=${this.hass}
.cameraConfig=${cameraConfig}
.proxyConfig=${this.camera.getLiveProxyConfig()}
class=${classMap({
...classes,
// The image provider is providing the temporary loading image,
+52 -98
View File
@@ -6,35 +6,26 @@ import {
TemplateResult,
unsafeCSS,
} from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { customElement, property } from 'lit/decorators.js';
import { Camera } from '../../../../camera-manager/camera.js';
import { CameraEndpoints } from '../../../../camera-manager/types.js';
import { MicrophoneState } from '../../../../card-controller/types.js';
import { dispatchLiveErrorEvent } from '../../../../components-lib/live/utils/dispatch-live-error.js';
import { VideoMediaPlayerController } from '../../../../components-lib/media-player/video.js';
import { SignedURLController } from '../../../../components-lib/signed-url-controller.js';
import { MicrophoneConfig } from '../../../../config/schema/live.js';
import { homeAssistantSignPath } from '../../../../ha/sign-path.js';
import { HomeAssistant } from '../../../../ha/types.js';
import { createProxiedEndpointIfNecessary } from '../../../../ha/web-proxy.js';
import { localize } from '../../../../localize/localize.js';
import liveGo2RTCStyle from '../../../../scss/live-go2rtc.scss';
import { MediaPlayer, MediaPlayerController, Message } from '../../../../types.js';
import { errorToConsole } from '../../../../utils/basic.js';
import { MediaPlayer, MediaPlayerController } from '../../../../types.js';
import { renderMessage } from '../../../message.js';
import { VideoRTC } from './video-rtc.js';
customElements.define('advanced-camera-card-live-go2rtc-player', VideoRTC);
// Note (2023-02-18): Depending on the behavior of the player / browser is
// possible this URL will need to be re-signed in order to avoid HA spamming
// logs after the expiry time, but this complexity is not added for now until
// there are verified cases of this being an issue (see equivalent in the JSMPEG
// provider).
const GO2RTC_URL_SIGN_EXPIRY_SECONDS = 24 * 60 * 60;
@customElement('advanced-camera-card-live-go2rtc')
export class AdvancedCameraCardGo2RTC extends LitElement implements MediaPlayer {
// Not an reactive property to avoid resetting the video.
// Not a reactive property to avoid resetting the video.
public hass?: HomeAssistant;
@property({ attribute: false })
@@ -52,10 +43,8 @@ export class AdvancedCameraCardGo2RTC extends LitElement implements MediaPlayer
@property({ attribute: true, type: Boolean })
public controls = false;
@state()
private _message: Message | null = null;
private _player?: VideoRTC;
private _hasLiveError = false;
private _mediaPlayerController = new VideoMediaPlayerController(
this,
@@ -63,13 +52,29 @@ export class AdvancedCameraCardGo2RTC extends LitElement implements MediaPlayer
() => this.controls,
);
private _signedURLController = new SignedURLController(
this,
() => {
const endpoint = this.cameraEndpoints?.go2rtc;
if (!this.hass || !endpoint) {
return {};
}
return {
hass: this.hass,
endpoint,
proxyConfig: this.camera?.getLiveProxyConfig(),
proxyEndpointOptions: { websocket: true },
};
},
() => this._createPlayer(),
);
public async getMediaPlayerController(): Promise<MediaPlayerController | null> {
return this._mediaPlayerController;
}
disconnectedCallback(): void {
this._player = undefined;
this._message = null;
super.disconnectedCallback();
}
@@ -81,82 +86,8 @@ export class AdvancedCameraCardGo2RTC extends LitElement implements MediaPlayer
this.requestUpdate();
}
private _handleError(message: Message, e?: Error): void {
if (e) {
errorToConsole(e as Error);
}
this._message = {
type: 'error',
...message,
};
dispatchLiveErrorEvent(this);
return;
}
private async _getPlayerSource(): Promise<string | null> {
const cameraConfig = this.camera?.getConfig();
const proxyConfig = this.camera?.getProxyConfig();
if (!this.hass || !cameraConfig) {
return null;
}
const streamEndpoint = this.cameraEndpoints?.go2rtc;
if (!streamEndpoint) {
this._handleError({
message: localize('error.live_camera_no_endpoint'),
context: cameraConfig,
});
return null;
}
let result: string | null = null;
try {
const endpoint = await createProxiedEndpointIfNecessary(
this.hass,
streamEndpoint,
proxyConfig,
{
context: 'live',
ttl: GO2RTC_URL_SIGN_EXPIRY_SECONDS,
websocket: true,
// The link may need to be opened multiple times.
openLimit: 0,
},
);
if (endpoint.sign) {
result = await homeAssistantSignPath(
this.hass,
endpoint.endpoint,
GO2RTC_URL_SIGN_EXPIRY_SECONDS,
);
if (!result) {
this._handleError({
message: localize('error.failed_sign'),
context: cameraConfig,
});
}
} else {
result = endpoint.endpoint;
}
} catch (e) {
this._handleError(
{
message: localize('error.failed_proxy'),
context: cameraConfig,
},
e as Error,
);
}
return result;
}
private async _createPlayer(): Promise<void> {
const src = await this._getPlayerSource();
private _createPlayer(): void {
const src = this._signedURLController.getValue();
if (!src) {
return;
}
@@ -178,12 +109,20 @@ export class AdvancedCameraCardGo2RTC extends LitElement implements MediaPlayer
protected willUpdate(changedProps: PropertyValues): void {
if (changedProps.has('cameraEndpoints')) {
this._message = null;
// Clear old player; the new one is created by the
// SignedURLController's valueChangeCallback once the URL resolves.
this._player = undefined;
}
if (!this._message && (!this._player || changedProps.has('cameraEndpoints'))) {
this._createPlayer();
// Only treat a missing go2rtc endpoint as an error after cameraEndpoints
// has been explicitly set (not undefined / still loading).
const hasError =
!!this._signedURLController.getError() ||
(!!this.cameraEndpoints && !this.cameraEndpoints.go2rtc);
if (hasError && !this._hasLiveError) {
dispatchLiveErrorEvent(this);
}
this._hasLiveError = hasError;
if (changedProps.has('controls') && this._player) {
this._player.setControls(this.controls);
@@ -203,8 +142,22 @@ export class AdvancedCameraCardGo2RTC extends LitElement implements MediaPlayer
}
protected render(): TemplateResult | void {
if (this._message) {
return renderMessage(this._message);
const error = this._signedURLController.getError();
if (error) {
return renderMessage({
type: 'error',
message: localize(
error === 'proxy' ? 'error.failed_proxy' : 'error.failed_sign',
),
context: this.camera?.getConfig(),
});
}
if (!this.cameraEndpoints?.go2rtc) {
return renderMessage({
type: 'error',
message: localize('error.live_camera_no_endpoint'),
context: this.camera?.getConfig(),
});
}
return html`${this._player}`;
}
@@ -216,6 +169,7 @@ export class AdvancedCameraCardGo2RTC extends LitElement implements MediaPlayer
declare global {
interface HTMLElementTagNameMap {
'advanced-camera-card-live-go2rtc-player': VideoRTC;
'advanced-camera-card-live-go2rtc': AdvancedCameraCardGo2RTC;
}
}
+5
View File
@@ -2,6 +2,7 @@ import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit
import { customElement, property } from 'lit/decorators.js';
import { createRef, ref, Ref } from 'lit/directives/ref.js';
import { CameraConfig } from '../../../config/schema/cameras';
import { EnabledProxyConfig } from '../../../config/schema/common/proxy';
import { HomeAssistant } from '../../../ha/types';
import basicBlockStyle from '../../../scss/basic-block.scss';
import {
@@ -19,6 +20,9 @@ export class AdvancedCameraCardLiveImage extends LitElement implements MediaPlay
@property({ attribute: false })
public cameraConfig?: CameraConfig;
@property({ attribute: false })
public proxyConfig?: EnabledProxyConfig;
private _refImage: Ref<MediaPlayerElement> = createRef();
public async getMediaPlayerController(): Promise<MediaPlayerController | null> {
@@ -37,6 +41,7 @@ export class AdvancedCameraCardLiveImage extends LitElement implements MediaPlay
.hass=${this.hass}
.imageConfig=${this.cameraConfig.image}
.cameraConfig=${this.cameraConfig}
.proxyConfig=${this.proxyConfig}
>
</advanced-camera-card-image-updating-player>
`;
+3 -3
View File
@@ -14,7 +14,7 @@ import { dispatchLiveErrorEvent } from '../../../components-lib/live/utils/dispa
import { JSMPEGMediaPlayerController } from '../../../components-lib/media-player/jsmpeg.js';
import { CameraConfig } from '../../../config/schema/cameras.js';
import { CardWideConfig } from '../../../config/schema/types.js';
import { homeAssistantSignPath } from '../../../ha/sign-path.js';
import { homeAssistantGetSignedURLIfNecessary } from '../../../ha/sign-path.js';
import { HomeAssistant } from '../../../ha/types.js';
import { localize } from '../../../localize/localize.js';
import liveJSMPEGStyle from '../../../scss/live-jsmpeg.scss';
@@ -186,9 +186,9 @@ export class AdvancedCameraCardLiveJSMPEG extends LitElement implements MediaPla
let response: string | null | undefined;
try {
response = await homeAssistantSignPath(
response = await homeAssistantGetSignedURLIfNecessary(
this.hass,
endpoint.endpoint,
endpoint,
JSMPEG_URL_SIGN_EXPIRY_SECONDS,
);
} catch (e) {
+55 -72
View File
@@ -6,13 +6,14 @@ import {
TemplateResult,
unsafeCSS,
} from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { customElement, property } from 'lit/decorators.js';
import { guard } from 'lit/directives/guard.js';
import { createRef, Ref, ref } from 'lit/directives/ref.js';
import { CameraManager } from '../../camera-manager/manager.js';
import { QueryType } from '../../camera-manager/types.js';
import { ViewManagerEpoch } from '../../card-controller/view/types.js';
import { LazyLoadController } from '../../components-lib/lazy-load-controller.js';
import { SignedURLController } from '../../components-lib/signed-url-controller.js';
import { ZoomSettingsObserved } from '../../components-lib/zoom/types.js';
import { handleZoomSettingsObservedEvent } from '../../components-lib/zoom/zoom-view-context.js';
import { CameraConfig } from '../../config/schema/cameras.js';
@@ -21,17 +22,16 @@ import { ViewerConfig } from '../../config/schema/viewer.js';
import { canonicalizeHAURL } from '../../ha/canonical-url.js';
import { isHARelativeURL } from '../../ha/is-ha-relative-url.js';
import { ResolvedMediaCache, resolveMedia } from '../../ha/resolved-media.js';
import { homeAssistantSignPath } from '../../ha/sign-path.js';
import { HomeAssistant, ResolvedMedia } from '../../ha/types.js';
import { createProxiedEndpointIfNecessary } from '../../ha/web-proxy.js';
import { HomeAssistant } from '../../ha/types.js';
import { localize } from '../../localize/localize.js';
import '../../patches/ha-hls-player.js';
import viewerProviderStyle from '../../scss/viewer-provider.scss';
import { MediaPlayer, MediaPlayerController, MediaPlayerElement } from '../../types.js';
import { errorToConsole } from '../../utils/basic.js';
import { ViewItemClassifier } from '../../view/item-classifier.js';
import { VideoContentType, ViewMedia } from '../../view/item.js';
import { UnifiedQueryTransformer } from '../../view/unified-query-transformer.js';
import '../image-player.js';
import { renderMessage } from '../message.js';
import { renderProgressIndicator } from '../progress-indicator.js';
import '../video-player.js';
import './../media-dimensions-container';
@@ -60,15 +60,32 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
public cardWideConfig?: CardWideConfig;
private _refProvider: Ref<MediaPlayerElement> = createRef();
private _refContainer: Ref<HTMLElement> = createRef();
private _lazyLoadController: LazyLoadController = new LazyLoadController(this);
@state()
private _url: string | null = null;
private _resolvedMediaURL: string | null = null;
private _signedURLController = new SignedURLController(this, () => {
if (!this.hass || !this._resolvedMediaURL) {
return {};
}
// HA-relative URLs need no proxying or signing.
if (isHARelativeURL(this._resolvedMediaURL)) {
return {
endpoint: { endpoint: canonicalizeHAURL(this.hass, this._resolvedMediaURL) },
};
}
const cameraID = this.media?.getCameraID();
const camera = cameraID ? this.cameraManager?.getStore().getCamera(cameraID) : null;
return {
hass: this.hass,
endpoint: { endpoint: this._resolvedMediaURL },
proxyConfig: camera?.getMediaProxyConfig(),
};
});
constructor() {
super();
this._lazyLoadController.addListener((loaded) => loaded && this._setURL());
this._lazyLoadController.addListener((loaded) => loaded && this._resolveURL());
}
public async getMediaPlayerController(): Promise<MediaPlayerController | null> {
@@ -108,69 +125,23 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
});
}
private async _setURL(): Promise<void> {
const mediaContentID = this.media?.getContentID();
if (
!this.media ||
!mediaContentID ||
!this.hass ||
!this._lazyLoadController?.isLoaded()
) {
private async _resolveURL(): Promise<void> {
const contentID = this.media?.getContentID();
if (!contentID || !this.hass || !this._lazyLoadController?.isLoaded()) {
this._resolvedMediaURL = null;
return;
}
let resolvedMedia: ResolvedMedia | null =
this.resolvedMediaCache?.get(mediaContentID) ?? null;
if (!resolvedMedia) {
resolvedMedia = await resolveMedia(
this.hass,
mediaContentID,
this.resolvedMediaCache,
);
}
// Clear immediately so the SignedURLController doesn't see a stale URL
// from the previous media item during the async gap.
this._resolvedMediaURL = null;
if (!resolvedMedia) {
return;
}
const resolved =
this.resolvedMediaCache?.get(contentID) ??
(await resolveMedia(this.hass, contentID, this.resolvedMediaCache));
const unsignedURL = resolvedMedia.url;
if (isHARelativeURL(unsignedURL)) {
// No need to proxy or sign local resolved URLs.
this._url = canonicalizeHAURL(this.hass, unsignedURL);
return;
}
const cameraID = this.media.getCameraID();
const camera = cameraID ? this.cameraManager?.getStore().getCamera(cameraID) : null;
const proxyConfig = camera?.getProxyConfig();
if (!proxyConfig) {
this._url = unsignedURL;
return;
}
try {
// Create endpoint from unsigned URL - it doesn't need signing initially
const unsignedEndpoint = { endpoint: unsignedURL, sign: false };
const proxiedEndpoint = await createProxiedEndpointIfNecessary(
this.hass,
unsignedEndpoint,
proxyConfig,
{
context: 'media',
// The link may need to be opened multiple times.
openLimit: 0,
},
);
if (proxiedEndpoint.sign) {
this._url = await homeAssistantSignPath(this.hass, proxiedEndpoint.endpoint);
} else {
this._url = proxiedEndpoint.endpoint;
}
} catch (e) {
errorToConsole(e as Error);
}
this._resolvedMediaURL = resolved?.url ?? null;
this.requestUpdate();
}
protected willUpdate(changedProps: PropertyValues): void {
@@ -187,7 +158,7 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
changedProps.has('resolvedMediaCache') ||
changedProps.has('hass')
) {
this._setURL();
this._resolveURL();
}
if (changedProps.has('viewerConfig') && this.viewerConfig?.zoomable) {
@@ -260,7 +231,19 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
return;
}
if (!this._url) {
const error = this._signedURLController.getError();
if (error) {
return renderMessage({
type: 'error',
message: localize(
error === 'proxy' ? 'error.failed_proxy' : 'error.failed_sign',
),
context: this.media?.getContentID(),
});
}
const url = this._signedURLController.getValue();
if (!url) {
return renderProgressIndicator({
cardWideConfig: this.cardWideConfig,
});
@@ -280,7 +263,7 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
muted
playsinline
title="${this.media.getTitle() ?? ''}"
url=${this._url}
url=${url}
.hass=${this.hass}
?controls=${this.viewerConfig.controls.builtin}
>
@@ -288,7 +271,7 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
: html`
<advanced-camera-card-video-player
${ref(this._refProvider)}
url=${this._url}
url=${url}
aria-label="${this.media.getTitle() ?? ''}"
title="${this.media.getTitle() ?? ''}"
?controls=${this.viewerConfig.controls.builtin}
@@ -297,7 +280,7 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
`
: html`<advanced-camera-card-image-player
${ref(this._refProvider)}
url="${this._url}"
url="${url}"
aria-label="${this.media.getTitle() ?? ''}"
title="${this.media.getTitle() ?? ''}"
@click=${() => {