Fix Frigate timeline playback authentication

master
Carlo Costanzo 3 weeks ago
parent 721baafe20
commit c998603de5

@ -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.

@ -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

@ -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.");
}
Loading…
Cancel
Save

Powered by TurnKey Linux.