From db50e36c76589d7e0345a27e299d4fcc17053f89 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 7 Oct 2023 18:29:31 -0700 Subject: [PATCH] Add support for custom go2rtc URLs. --- README.md | 5 +- src/camera-manager/frigate/engine-frigate.ts | 22 +- src/components/live/live-go2rtc.ts | 4 +- src/config/types.ts | 1 + .../frigate/engine-frigate.test.ts | 290 +++++++++++++++++- 5 files changed, 306 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 68209889..ec39d229 100644 --- a/README.md +++ b/README.md @@ -249,7 +249,8 @@ cameras: | Option | Default | Overridable | Description | | - | - | - | - | | `modes` | `[webrtc, mse, mp4, mjpeg]` | :white_check_mark: | An ordered array of `go2rtc` modes to use. Valid values are `webrtc`, `mse`, `mp4` or `mjpeg` values. | -| `stream` | Determind by camera engine (e.g. `frigate` camera name). | :white_check_mark: | A valid `go2rtc` stream name. | +| `stream` | Determined by camera engine (e.g. `frigate` camera name). | :white_check_mark: | A valid `go2rtc` stream name. | +| `url` | Determined by camera engine (e.g. the `frigate` engine will automatically generate a URL for the go2rtc backend that runs in the Frigate container). | :white_check_mark: | The root `go2rtc` backend URL the card should stream the video from. This will only need to be set in advanced cases -- for the vast majority of people the default will work. Example: `http://my-custom-go2rtc:1984` | #### Live Provider: Camera WebRTC Card configuration @@ -1745,6 +1746,7 @@ cameras: - mp4 - mjpeg stream: sitting_room + url: 'https://my.custom.go2rtc.backend' - camera_entity: camera.sitting_room_webrtc_card live_provider: webrtc_card webrtc_card: @@ -1816,6 +1818,7 @@ cameras_global: - mp4 - mjpeg stream: sitting_room + url: 'https://my.custom.go2rtc.backend' webrtc_card: # Arbitrary WebRTC Card options, see https://github.com/AlexxIT/WebRTC#configuration . entity: camera.sitting_room_rtsp diff --git a/src/camera-manager/frigate/engine-frigate.ts b/src/camera-manager/frigate/engine-frigate.ts index 6301cd93..79f9625a 100644 --- a/src/camera-manager/frigate/engine-frigate.ts +++ b/src/camera-manager/frigate/engine-frigate.ts @@ -1148,16 +1148,22 @@ export class FrigateCameraManagerEngine }; const getGo2RTC = (): CameraEndpoint | null => { - return { - endpoint: - `/api/frigate/${cameraConfig.frigate.client_id}` + - // go2rtc is exposed by the integration under the (slightly + const stream = cameraConfig.go2rtc?.stream ?? cameraConfig.frigate.camera_name; + const url = cameraConfig.go2rtc?.url + ? cameraConfig.go2rtc.url.replace(/\/+$/, '') + : null; + const endpoint = url + ? `${url}/api/ws?src=${stream}` + : // go2rtc is exposed by the integration under the (slightly // misleading) 'mse' path, even though that path can serve all go2rtc // modes. - `/mse/api/ws?src=${ - cameraConfig.go2rtc?.stream ?? cameraConfig.frigate.camera_name - }`, - sign: true, + `/api/frigate/${cameraConfig.frigate.client_id}/mse/api/ws?src=${stream}`; + + return { + endpoint: endpoint, + + // Only sign the endpoint if it's local to HA. + sign: endpoint.startsWith('/'), }; }; diff --git a/src/components/live/live-go2rtc.ts b/src/components/live/live-go2rtc.ts index 1602e774..be722aba 100644 --- a/src/components/live/live-go2rtc.ts +++ b/src/components/live/live-go2rtc.ts @@ -10,7 +10,7 @@ import { customElement, property } from 'lit/decorators.js'; import { CameraEndpoints } from '../../camera-manager/types.js'; import { CameraConfig, MicrophoneConfig } from '../../config/types.js'; import { localize } from '../../localize/localize'; -import liveMSEStyle from '../../scss/live-go2rtc.scss'; +import liveGo2RTCStyle from '../../scss/live-go2rtc.scss'; import { ExtendedHomeAssistant, FrigateCardMediaPlayer } from '../../types.js'; import { getEndpointAddressOrDispatchError } from '../../utils/endpoint'; import { setControlsOnVideo } from '../../utils/media.js'; @@ -167,7 +167,7 @@ export class FrigateCardGo2RTC extends LitElement implements FrigateCardMediaPla } static get styles(): CSSResultGroup { - return unsafeCSS(liveMSEStyle); + return unsafeCSS(liveGo2RTCStyle); } } diff --git a/src/config/types.ts b/src/config/types.ts index 88574ce7..6b61520c 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -774,6 +774,7 @@ const microphoneConfigSchema = z export type MicrophoneConfig = z.infer; const go2rtcConfigSchema = z.object({ + url: z.string().optional(), modes: z.enum(['webrtc', 'mse', 'mp4', 'mjpeg']).array().optional(), stream: z.string().optional(), }); diff --git a/tests/camera-manager/frigate/engine-frigate.test.ts b/tests/camera-manager/frigate/engine-frigate.test.ts index da11bcb7..3bf4e54c 100644 --- a/tests/camera-manager/frigate/engine-frigate.test.ts +++ b/tests/camera-manager/frigate/engine-frigate.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { RecordingSegmentsCache, RequestCache } from '../../../src/camera-manager/cache'; import { FrigateCameraManagerEngine } from '../../../src/camera-manager/frigate/engine-frigate'; import { @@ -6,9 +6,13 @@ import { FrigateRecordingViewMedia, } from '../../../src/camera-manager/frigate/media'; import { FrigateEvent, eventSchema } from '../../../src/camera-manager/frigate/types.js'; -import { CameraConfig, RawFrigateCardConfig } from '../../../src/config/types'; +import { + CameraConfig, + FrigateCardView, + RawFrigateCardConfig, +} from '../../../src/config/types'; import { ViewMedia } from '../../../src/view/media'; -import { createCameraConfig, createHASS } from '../../test-utils'; +import { TestViewMedia, createCameraConfig, createHASS } from '../../test-utils'; const createEngine = (): FrigateCameraManagerEngine => { return new FrigateCameraManagerEngine( @@ -80,8 +84,6 @@ const createFrigateCameraConfig = (config?: RawFrigateCardConfig): CameraConfig }; describe('getMediaDownloadPath', () => { - afterEach(() => {}); - it('should get event with clip download path', async () => { const endpoint = await createEngine().getMediaDownloadPath( createHASS(), @@ -131,3 +133,281 @@ describe('getMediaDownloadPath', () => { expect(endpoint).toBeNull(); }); }); + +describe('getCameraEndpoints', () => { + it('should get basic endpoints', () => { + const endpoints = createEngine().getCameraEndpoints(createFrigateCameraConfig()); + + expect(endpoints).toEqual({ + go2rtc: { + endpoint: '/api/frigate/frigate/mse/api/ws?src=camera-1', + sign: true, + }, + jsmpeg: { + endpoint: '/api/frigate/frigate/jsmpeg/camera-1', + sign: true, + }, + webrtcCard: { + endpoint: 'camera-1', + }, + }); + }); + + describe('should get overridden go2rtc url', () => { + it('when local HA path', () => { + const endpoints = createEngine().getCameraEndpoints( + createFrigateCameraConfig({ + go2rtc: { + url: '/local/path', + }, + }), + ); + + expect(endpoints).toEqual( + expect.objectContaining({ + go2rtc: { + endpoint: '/local/path/api/ws?src=camera-1', + sign: true, + }, + }), + ); + }); + + it('when remote', () => { + const endpoints = createEngine().getCameraEndpoints( + createFrigateCameraConfig({ + go2rtc: { + url: 'https://my.custom.go2rtc', + }, + }), + ); + + expect(endpoints).toEqual( + expect.objectContaining({ + go2rtc: { + endpoint: 'https://my.custom.go2rtc/api/ws?src=camera-1', + sign: false, + }, + }), + ); + }); + + it('when remote with superfluous slashes', () => { + const endpoints = createEngine().getCameraEndpoints( + createFrigateCameraConfig({ + go2rtc: { + url: 'https://my.custom.go2rtc///', + }, + }), + ); + + expect(endpoints).toEqual( + expect.objectContaining({ + go2rtc: { + endpoint: 'https://my.custom.go2rtc/api/ws?src=camera-1', + sign: false, + }, + }), + ); + }); + }); + + it('should not set webrtc_card endpoint without camera name', () => { + const endpoints = createEngine().getCameraEndpoints(createCameraConfig()); + + expect(endpoints).not.toEqual( + expect.objectContaining({ + webrtcCard: expect.anything(), + }), + ); + }); + + describe('should include UI endpoint', () => { + it('with basic url', () => { + const endpoints = createEngine().getCameraEndpoints( + createCameraConfig({ + frigate: { + url: 'http://my.frigate', + }, + }), + ); + + expect(endpoints).not.toEqual( + expect.objectContaining({ + ui: { + url: 'http://my.frigate', + }, + }), + ); + }); + + it('with camera name', () => { + const endpoints = createEngine().getCameraEndpoints( + createCameraConfig({ + frigate: { + url: 'http://my.frigate', + camera_name: 'my-camera', + }, + }), + ); + + expect(endpoints).not.toEqual( + expect.objectContaining({ + ui: { + url: 'http://my.frigate/cameras/my-camera', + }, + }), + ); + }); + + describe('with event media type', () => { + it.each([['clip' as const], ['snapshot' as const]])( + '%s', + (mediaType: 'clip' | 'snapshot') => { + const endpoints = createEngine().getCameraEndpoints( + createCameraConfig({ + frigate: { + url: 'http://my.frigate', + camera_name: 'my-camera', + }, + }), + { + media: new TestViewMedia({ mediaType: mediaType }), + }, + ); + + expect(endpoints).not.toEqual( + expect.objectContaining({ + ui: { + url: 'http://my.frigate/events?camera=my-camera', + }, + }), + ); + }, + ); + }); + + describe('with recording media type', () => { + it('with start time', () => { + const startTime = new Date('2023-10-07T16:42:00'); + const endpoints = createEngine().getCameraEndpoints( + createCameraConfig({ + frigate: { + url: 'http://my.frigate', + camera_name: 'my-camera', + }, + }), + { + media: new TestViewMedia({ mediaType: 'recording', startTime: startTime }), + }, + ); + + expect(endpoints).not.toEqual( + expect.objectContaining({ + ui: { + url: 'http://my.frigate/recording/my-camera/2023-10-07/16', + }, + }), + ); + }); + + it('without start time', () => { + const endpoints = createEngine().getCameraEndpoints( + createCameraConfig({ + frigate: { + url: 'http://my.frigate', + camera_name: 'my-camera', + }, + }), + { + media: new TestViewMedia({ mediaType: 'recording' }), + }, + ); + + expect(endpoints).not.toEqual( + expect.objectContaining({ + ui: { + url: 'http://my.frigate/recording/my-camera/', + }, + }), + ); + }); + }); + + describe('with view', () => { + it('live', () => { + const endpoints = createEngine().getCameraEndpoints( + createCameraConfig({ + frigate: { + url: 'http://my.frigate', + camera_name: 'my-camera', + }, + }), + { + view: 'live', + }, + ); + + expect(endpoints).not.toEqual( + expect.objectContaining({ + ui: { + url: 'http://my.frigate/cameras/my-camera', + }, + }), + ); + }); + + it.each([ + ['clip' as const], + ['clips' as const], + ['snapshot' as const], + ['snapshots' as const], + ])('%s', (viewName: FrigateCardView) => { + const endpoints = createEngine().getCameraEndpoints( + createCameraConfig({ + frigate: { + url: 'http://my.frigate', + camera_name: 'my-camera', + }, + }), + { + view: viewName, + }, + ); + + expect(endpoints).not.toEqual( + expect.objectContaining({ + ui: { + url: 'http://my.frigate/events?camera=my-camera', + }, + }), + ); + }); + + it.each([['recording' as const], ['recordings' as const]])( + '%s', + (viewName: FrigateCardView) => { + const endpoints = createEngine().getCameraEndpoints( + createCameraConfig({ + frigate: { + url: 'http://my.frigate', + camera_name: 'my-camera', + }, + }), + { + view: viewName, + }, + ); + + expect(endpoints).not.toEqual( + expect.objectContaining({ + ui: { + url: 'http://my.frigate/recording/my-camera/', + }, + }), + ); + }, + ); + }); + }); +});