Compare commits

...
1 Commits
Author SHA1 Message Date
Petar Petrov 2e95634e77 Cache resolved media source URLs for view backgrounds 2026-08-12 15:10:16 +03:00
5 changed files with 312 additions and 26 deletions
+19 -1
View File
@@ -1,3 +1,4 @@
import { timeCacheEntityPromiseFunc } from "../common/util/time-cache-entity-promise-func";
import type { HomeAssistant } from "../types";
import type { MediaPlayerItem, SearchMediaResult } from "./media-player";
@@ -7,7 +8,7 @@ export interface ResolvedMediaSource {
}
export const resolveMediaSource = (
hass: HomeAssistant,
hass: Pick<HomeAssistant, "callWS">,
media_content_id: string
) =>
hass.callWS<ResolvedMediaSource>({
@@ -15,6 +16,23 @@ export const resolveMediaSource = (
media_content_id,
});
// Resolved URLs are signed and valid for 24 hours (CONTENT_AUTH_EXPIRY_TIME in
// core). Resolving again returns a different signature, which would defeat the
// browser cache, so reuse the resolved URL for just under its validity.
export const RESOLVE_CACHE_TIME = 23 * 60 * 60 * 1000; // 23 hours
export const resolveMediaSourceWithCache = (
hass: Pick<HomeAssistant, "callWS" | "hassUrl">,
media_content_id: string
): Promise<ResolvedMediaSource> =>
timeCacheEntityPromiseFunc(
"_resolvedMediaSource",
RESOLVE_CACHE_TIME,
resolveMediaSource,
hass,
media_content_id
);
export const browseLocalMediaPlayer = (
hass: HomeAssistant,
mediaContentId?: string
@@ -11,7 +11,7 @@ import type { LocalizeFunc } from "../../../../common/translations/localize";
import {
isMediaSourceContentId,
resolveMediaSource,
resolveMediaSourceWithCache,
} from "../../../../data/media_source";
@customElement("hui-view-background-editor")
@@ -136,21 +136,35 @@ export class HuiViewBackgroundEditor extends LitElement {
`${(background.opacity ?? 100) / 100}`
);
const backgroundImage =
typeof background.image === "object"
? background.image.media_content_id
: background.image;
const backgroundImage = this._currentBackgroundImage();
if (backgroundImage && isMediaSourceContentId(backgroundImage)) {
resolveMediaSource(this.hass, backgroundImage).then((result) => {
this._resolvedImage = result.url;
});
resolveMediaSourceWithCache(this.hass, backgroundImage).then(
(result) => {
// Discard if the image changed while resolving
if (this._currentBackgroundImage() === backgroundImage) {
this._resolvedImage = result.url;
}
},
() => {
if (this._currentBackgroundImage() === backgroundImage) {
this._resolvedImage = undefined;
}
}
);
} else {
this._resolvedImage = backgroundImage;
}
}
}
private _currentBackgroundImage(): string | undefined {
const background = this._backgroundData(this._config);
return typeof background.image === "object"
? background.image.media_content_id
: background.image;
}
protected render() {
if (!this.hass) {
return nothing;
@@ -5,7 +5,7 @@ import type { HomeAssistant } from "../../../types";
import type { LovelaceViewBackgroundConfig } from "../../../data/lovelace/config/view";
import {
isMediaSourceContentId,
resolveMediaSource,
resolveMediaSourceWithCache,
} from "../../../data/media_source";
@customElement("hui-view-background")
@@ -21,20 +21,37 @@ export class HUIViewBackground extends LitElement {
return nothing;
}
private _fetchMedia() {
const backgroundImage =
typeof this.background === "string"
? this.background
: typeof this.background?.image === "object"
? this.background.image.media_content_id
: this.background?.image;
private _getBackgroundImage(
background?: string | LovelaceViewBackgroundConfig
): string | undefined {
if (typeof background === "string") {
return background;
}
if (typeof background?.image === "object") {
return background.image.media_content_id;
}
return background?.image;
}
if (backgroundImage && isMediaSourceContentId(backgroundImage)) {
resolveMediaSource(this.hass, backgroundImage).then((result) => {
this.resolvedImage = result.url;
});
} else {
private async _fetchMedia() {
const backgroundImage = this._getBackgroundImage(this.background);
if (!backgroundImage || !isMediaSourceContentId(backgroundImage)) {
this.resolvedImage = undefined;
return;
}
let resolvedUrl: string | undefined;
try {
resolvedUrl = (
await resolveMediaSourceWithCache(this.hass, backgroundImage)
).url;
} catch {
resolvedUrl = undefined;
}
// Discard if the background changed while resolving
if (this._getBackgroundImage(this.background) === backgroundImage) {
this.resolvedImage = resolvedUrl;
}
}
@@ -73,10 +90,7 @@ export class HUIViewBackground extends LitElement {
background?: string | LovelaceViewBackgroundConfig
) {
if (typeof background === "object" && background.image) {
const image =
typeof background.image === "object"
? background.image.media_content_id || ""
: background.image;
const image = this._getBackgroundImage(background) || "";
if (isMediaSourceContentId(image) && !this.resolvedImage) {
return null;
}
+111
View File
@@ -0,0 +1,111 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
RESOLVE_CACHE_TIME,
resolveMediaSourceWithCache,
} from "../../src/data/media_source";
import type { HomeAssistant } from "../../src/types";
const CONTENT_ID = "media-source://image_upload/background";
const OTHER_CONTENT_ID = "media-source://image_upload/other";
// Core signs every resolution with a fresh timestamp, so an uncached resolve of
// the same id yields a different url each time. The mock reproduces that: the
// urls only stay equal if the resolution itself was reused.
const mockHass = () => {
let signature = 0;
return {
callWS: vi.fn(({ media_content_id }: { media_content_id: string }) => {
signature += 1;
return Promise.resolve({
url: `/api/image/serve/${media_content_id.split("/").pop()}/original?authSig=sig${signature}`,
mime_type: "image/jpeg",
});
}),
} as unknown as HomeAssistant;
};
describe("resolveMediaSourceWithCache", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it("returns the same url when the same content id is resolved again", async () => {
const hass = mockHass();
const first = await resolveMediaSourceWithCache(hass, CONTENT_ID);
const second = await resolveMediaSourceWithCache(hass, CONTENT_ID);
expect(second.url).toBe(first.url);
expect(hass.callWS).toHaveBeenCalledTimes(1);
});
it("shares a single request between concurrent callers", async () => {
const hass = mockHass();
const [first, second] = await Promise.all([
resolveMediaSourceWithCache(hass, CONTENT_ID),
resolveMediaSourceWithCache(hass, CONTENT_ID),
]);
expect(second.url).toBe(first.url);
expect(hass.callWS).toHaveBeenCalledTimes(1);
});
it("resolves each content id to its own url", async () => {
const hass = mockHass();
const first = await resolveMediaSourceWithCache(hass, CONTENT_ID);
const other = await resolveMediaSourceWithCache(hass, OTHER_CONTENT_ID);
expect(first.url).toContain("/background/");
expect(other.url).toContain("/other/");
expect(hass.callWS).toHaveBeenCalledTimes(2);
});
it("keeps returning the same url when hass is updated", async () => {
const hass = mockHass();
const first = await resolveMediaSourceWithCache(hass, CONTENT_ID);
// State updates replace hass with a shallow copy
const second = await resolveMediaSourceWithCache(
{ ...hass } as HomeAssistant,
CONTENT_ID
);
expect(second.url).toBe(first.url);
expect(hass.callWS).toHaveBeenCalledTimes(1);
});
it("does not cache failures", async () => {
const hass = mockHass();
vi.mocked(hass.callWS).mockRejectedValueOnce(new Error("unresolvable"));
await expect(
resolveMediaSourceWithCache(hass, CONTENT_ID)
).rejects.toThrowError("unresolvable");
const retried = await resolveMediaSourceWithCache(hass, CONTENT_ID);
expect(retried.url).toContain("authSig=");
expect(hass.callWS).toHaveBeenCalledTimes(2);
});
it("resolves a fresh url once the cached one is about to expire", async () => {
const hass = mockHass();
const first = await resolveMediaSourceWithCache(hass, CONTENT_ID);
vi.advanceTimersByTime(RESOLVE_CACHE_TIME);
const second = await resolveMediaSourceWithCache(hass, CONTENT_ID);
expect(second.url).not.toBe(first.url);
expect(hass.callWS).toHaveBeenCalledTimes(2);
});
it("caches for less than the 24 hour signature validity", () => {
expect(RESOLVE_CACHE_TIME).toBeLessThan(24 * 60 * 60 * 1000);
});
});
@@ -0,0 +1,129 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import "../../../../src/panels/lovelace/views/hui-view-background";
import type { HUIViewBackground } from "../../../../src/panels/lovelace/views/hui-view-background";
import type { LovelaceViewBackgroundConfig } from "../../../../src/data/lovelace/config/view";
import type { HomeAssistant } from "../../../../src/types";
const IMAGE_A = "media-source://image_upload/a";
const IMAGE_B = "media-source://image_upload/b";
// Resolutions are answered by hand so a slow one can land after a later,
// already-resolved one — the ordering a cache hit makes easy to hit.
const deferredHass = () => {
const pending = new Map<string, (url: string) => void>();
const hass = {
callWS: vi.fn(
({ media_content_id }: { media_content_id: string }) =>
new Promise((resolve) => {
pending.set(media_content_id, (url: string) =>
resolve({ url, mime_type: "image/jpeg" })
);
})
),
hassUrl: (path?: string) => path ?? "",
} as unknown as HomeAssistant;
return { hass, pending };
};
let elements: HUIViewBackground[] = [];
const mount = async (
hass: HomeAssistant,
background: string | LovelaceViewBackgroundConfig
) => {
const el = document.createElement("hui-view-background") as HUIViewBackground;
el.hass = hass;
el.background = background;
document.body.appendChild(el);
elements.push(el);
await el.updateComplete;
return el;
};
const setBackground = async (
el: HUIViewBackground,
background: string | LovelaceViewBackgroundConfig
) => {
el.background = background;
await el.updateComplete;
};
// Let the resolve chain drain before checking what was applied
const settle = async (el: HUIViewBackground) => {
await new Promise((resolve) => {
setTimeout(resolve, 0);
});
await el.updateComplete;
};
const imageBackground = (
mediaContentId: string
): LovelaceViewBackgroundConfig => ({
image: { media_content_id: mediaContentId },
});
const backgroundUrl = (el: HUIViewBackground) =>
el.style.getPropertyValue("--view-background");
afterEach(() => {
elements.forEach((el) => el.remove());
elements = [];
vi.restoreAllMocks();
});
describe("hui-view-background", () => {
it("applies the resolved url of a media source background", async () => {
const { hass, pending } = deferredHass();
const el = await mount(hass, imageBackground(IMAGE_A));
pending.get(IMAGE_A)!("/a.jpg?authSig=a");
await settle(el);
expect(backgroundUrl(el)).toContain("/a.jpg?authSig=a");
});
it("ignores a resolution that arrives after the background changed", async () => {
const { hass, pending } = deferredHass();
const el = await mount(hass, imageBackground(IMAGE_A));
await setBackground(el, imageBackground(IMAGE_B));
// B resolves first, then the stale A resolution lands
pending.get(IMAGE_B)!("/b.jpg?authSig=b");
await settle(el);
pending.get(IMAGE_A)!("/a.jpg?authSig=a");
await settle(el);
expect(backgroundUrl(el)).toContain("/b.jpg?authSig=b");
expect(backgroundUrl(el)).not.toContain("/a.jpg");
});
it("keeps a plain css background untouched", async () => {
const { hass } = deferredHass();
const el = await mount(hass, "#3f51b5");
expect(hass.callWS).not.toHaveBeenCalled();
expect(backgroundUrl(el)).toBe("#3f51b5");
});
it("clears the resolved image when the background is replaced by a color", async () => {
const { hass, pending } = deferredHass();
const el = await mount(hass, imageBackground(IMAGE_A));
pending.get(IMAGE_A)!("/a.jpg?authSig=a");
await settle(el);
await setBackground(el, "#3f51b5");
expect(backgroundUrl(el)).toBe("#3f51b5");
});
it("falls back to the theme background when resolving fails", async () => {
const hass = {
callWS: vi.fn().mockRejectedValue(new Error("unresolvable")),
hassUrl: (path?: string) => path ?? "",
} as unknown as HomeAssistant;
const el = await mount(hass, imageBackground(IMAGE_A));
await settle(el);
expect(backgroundUrl(el)).toBe("");
});
});