From c998603de58adf8448735c460cf15c19be238f73 Mon Sep 17 00:00:00 2001 From: Carlo Costanzo Date: Sat, 24 Oct 2026 17:25:31 -0400 Subject: [PATCH] Fix Frigate timeline playback authentication --- config/dashboards/README.md | 6 ++ config/dashboards/resources.yaml | 3 +- config/www/frigate-events-auth.js | 100 ++++++++++++++++++++++++++++++ 3 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 config/www/frigate-events-auth.js diff --git a/config/dashboards/README.md b/config/dashboards/README.md index abe56aaa..b4ae8d58 100644 --- a/config/dashboards/README.md +++ b/config/dashboards/README.md @@ -50,6 +50,12 @@ This folder holds YAML-managed Home Assistant Lovelace dashboards and UI resourc - `shared/` - Cross-dashboard reuse only. If something is only used by one dashboard, keep it in that dashboard folder. +### Camera Timeline Playback + +The Frigate Events Card keeps its event gallery and continuous-recording timeline. Its resource loads through `www/frigate-events-auth.js`, which imports the HACS card and signs the timeline manifest and MP4 fallback through Home Assistant. Frigate passes the manifest signature to the recording segments; native video playback also needs a signed URL. The adapter is scoped to this card and preserves the installed HACS bundle and direct Frigate URL behavior. + +This bridges the unsigned timeline URLs in Frigate Events Card v2.5.23. Recheck playback after card upgrades and remove the adapter when upstream signs both timeline paths. The adapter discards pending authorization when the camera, time window, or modal changes. + ### Mobile Quick Access - Mobile is the second visible Overview view, directly after Home and before Cameras. - Two-column controls use large labels, states, icons, and touch targets for quick use without glasses. diff --git a/config/dashboards/resources.yaml b/config/dashboards/resources.yaml index 4800890f..f5a5ec6b 100644 --- a/config/dashboards/resources.yaml +++ b/config/dashboards/resources.yaml @@ -50,7 +50,8 @@ - type: module url: /hacsfiles/lovelace-layout-card/layout-card.js?hacstag=156434866247 - type: module - url: /hacsfiles/frigate-events-card/frigate-events-card.js?hacstag=1111882479100 + # Loads the HACS card with signed timeline HLS and MP4 media URLs. + url: /local/frigate-events-auth.js?v=1 - type: module url: /local/ookla_speedtest/ookla-speedtest-card.js?v=3.0.5 - type: module diff --git a/config/www/frigate-events-auth.js b/config/www/frigate-events-auth.js new file mode 100644 index 00000000..e83c0e92 --- /dev/null +++ b/config/www/frigate-events-auth.js @@ -0,0 +1,100 @@ +// Frigate Events Card v2.5.23 signs thumbnails but leaves timeline media unsigned. +// Keep the HACS bundle intact and adapt only this card's timeline media loaders. +import "/hacsfiles/frigate-events-card/frigate-events-card.js?hacstag=1111882479100"; + +const Card = customElements.get("frigate-events-card"); +const originalLoad = Card?.prototype._loadTimelineVideo; +const patched = Symbol.for("frigate-events-card.signed-timeline"); +const cardStates = new WeakMap(); +const mediaSrc = Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, "src"); + +function stateFor(card) { + let state = cardStates.get(card); + if (state) return state; + state = { request: 0, urls: new Map(), videos: new WeakSet() }; + cardStates.set(card, state); + + // The card assigns Hls.js before calling loadSource. Translate that one URL; + // Frigate propagates the manifest's authSig to its init and media segments. + for (const field of ["_timelineHlsA", "_timelineHlsB"]) { + let value = card[field]; + Object.defineProperty(card, field, { + configurable: true, + get: () => value, + set(next) { + value = next; + if (!next) return; + const loadSource = next.loadSource; + next.loadSource = function (url) { + return loadSource.call(this, state.urls.get(url) || url); + }; + }, + }); + } + return state; +} + +function adaptVideo(video, state) { + if (!video || state.videos.has(video)) return; + state.videos.add(video); + // Native HLS and the MP4 fallback use video.src and cannot send a bearer + // header. Change only the two signed timeline paths, leaving blob URLs alone. + Object.defineProperty(video, "src", { + configurable: true, + get() { return mediaSrc.get.call(this); }, + set(url) { mediaSrc.set.call(this, state.urls.get(url) || url); }, + }); +} + +if (originalLoad && mediaSrc?.get && mediaSrc?.set && !Card.prototype[patched]) { + Card.prototype[patched] = true; + Card.prototype._loadTimelineVideo = async function (...args) { + if (!this._timelineContainer || !this._timelineCamera) return; + // A configured direct Frigate origin has its own authentication policy. + if (this._config?.frigate_url) return originalLoad.apply(this, args); + + const state = stateFor(this); + const request = ++state.request; + const container = this._timelineContainer; + const camera = this._timelineCamera; + const start = Math.floor(this._timelineStartTs); + const end = Math.floor(this._timelineEndTs); + const client = encodeURIComponent(this._config?.frigate_client_id || "frigate"); + const windowPath = `${encodeURIComponent(camera)}/start/${start}/end/${end}`; + const urls = [ + `/api/frigate/${client}/vod/${windowPath}/index.m3u8`, + `/api/frigate/${client}/recording/${windowPath}`, + ]; + + try { + const signed = await Promise.all(urls.map(async (path) => { + const result = await this.hass.callWS({ + type: "auth/sign_path", path, expires: 86400, + }); + if (!result?.path) throw new Error("Home Assistant returned no signed path"); + return [path, result.path]; + })); + // Camera/window changes or closing the modal invalidate pending signing. + if (state.request !== request || this._timelineContainer !== container + || this._timelineCamera !== camera + || Math.floor(this._timelineStartTs) !== start + || Math.floor(this._timelineEndTs) !== end) return; + + state.urls = new Map(signed); + adaptVideo(this._timelineVideoA, state); + adaptVideo(this._timelineVideoB, state); + return originalLoad.apply(this, args); + } catch (error) { + if (state.request !== request || this._timelineContainer !== container) return; + console.warn("Frigate timeline media authorization failed", error?.message); + this._isAdvancingTimeline = false; + const loading = container.querySelector(".timeline-player-loading"); + if (loading) { + loading.style.display = "flex"; + loading.textContent = "Unable to authorize footage. Close the timeline and try again."; + } + } + }; +} else if (!originalLoad) { + console.warn("Frigate timeline authentication adapter: card API changed; review compatibility."); +}