Add screenshot support.

This commit is contained in:
Dermot Duffy
2023-06-16 19:36:45 -07:00
parent ad23083d72
commit 39b985b4df
27 changed files with 413 additions and 72 deletions
+2 -25
View File
@@ -5,8 +5,9 @@ import {
getCameraEntityFromConfig,
sortMedia,
} from '../../src/camera-manager/util.js';
import { ViewMedia, ViewMediaType } from '../../src/view/media.js';
import { CameraConfig, cameraConfigSchema } from '../../src/types.js';
import { ViewMedia, ViewMediaType } from '../../src/view/media.js';
import { TestViewMedia } from '../test-utils.js';
describe('convertRangeToCacheFriendlyTimes', () => {
it('should return cache friendly within hour range', () => {
@@ -74,30 +75,6 @@ describe('capEndDate', () => {
});
});
// ViewMedia itself has no native way to set startTime and ID that aren't linked
// to an engine.
class TestViewMedia extends ViewMedia {
protected _ID: string | null;
protected _startTime: Date;
constructor(
ID: string | null,
startTime: Date,
mediaType: ViewMediaType,
cameraID: string,
) {
super(mediaType, cameraID);
this._ID = ID;
this._startTime = startTime;
}
public getID(): string | null {
return this._ID;
}
public getStartTime(): Date | null {
return this._startTime;
}
}
describe('sortMedia', () => {
const media_1 = new TestViewMedia(
'id-1',
+28 -3
View File
@@ -1,4 +1,3 @@
import { HomeAssistant } from 'custom-card-helpers';
import { HassEntities, HassEntity } from 'home-assistant-js-websocket';
import { vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
@@ -13,6 +12,7 @@ import {
} from '../src/camera-manager/types';
import {
CameraConfig,
ExtendedHomeAssistant,
FrigateCardCondition,
FrigateCardConfig,
MediaLoadedInfo,
@@ -22,6 +22,7 @@ import {
frigateCardConfigSchema,
} from '../src/types';
import { Entity } from '../src/utils/ha/entity-registry/types';
import { ViewMedia, ViewMediaType } from '../src/view/media';
import { View, ViewParameters } from '../src/view/view';
export const createCameraConfig = (config?: unknown): CameraConfig => {
@@ -42,8 +43,8 @@ export const createConfig = (config?: RawFrigateCardConfig): FrigateCardConfig =
});
};
export const createHASS = (states?: HassEntities): HomeAssistant => {
const hass = mock<HomeAssistant>();
export const createHASS = (states?: HassEntities): ExtendedHomeAssistant => {
const hass = mock<ExtendedHomeAssistant>();
if (states) {
hass.states = states;
}
@@ -167,3 +168,27 @@ export const createMediaLoadedInfo = (
...options,
};
};
// ViewMedia itself has no native way to set startTime and ID that aren't linked
// to an engine.
export class TestViewMedia extends ViewMedia {
protected _id: string | null;
protected _startTime: Date;
constructor(
id: string | null,
startTime: Date,
mediaType: ViewMediaType,
cameraID: string,
) {
super(mediaType, cameraID);
this._id = id;
this._startTime = startTime;
}
public getID(): string | null {
return this._id;
}
public getStartTime(): Date | null {
return this._startTime;
}
}
+1 -1
View File
@@ -4,7 +4,7 @@ import { log } from '../../src/utils/debug.js';
describe('log', () => {
const spy = vi.spyOn(global.console, 'debug').mockReturnValue(undefined);
afterAll(() => {
vi.resetAllMocks();
vi.restoreAllMocks();
});
it('should do nothing without debug logging set', () => {
log({}, 'foo');
+98
View File
@@ -0,0 +1,98 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { CameraManager } from '../../src/camera-manager/manager.js';
import { downloadMedia, downloadURL } from '../../src/utils/download';
import { homeAssistantSignPath } from '../../src/utils/ha';
import { ViewMedia } from '../../src/view/media';
import { createCameraManager, createHASS } from '../test-utils';
vi.mock('../../src/camera-manager/manager.js');
vi.mock('../../src/utils/ha');
const media = new ViewMedia('clip', 'camera-1');
// @vitest-environment jsdom
describe('downloadMedia', () => {
afterEach(() => {
vi.resetAllMocks();
global.window.location = mock<Location>();
});
it('should throw error when no media', async () => {
const cameraManager = createCameraManager();
mock<CameraManager>(cameraManager).getMediaDownloadPath.mockResolvedValue(null);
expect(downloadMedia(createHASS(), cameraManager, media)).rejects.toThrow(
/No media to download/,
);
});
it('should throw error when signing fails', () => {
vi.spyOn(global.console, 'warn').mockReturnValue(undefined);
const cameraManager = createCameraManager();
mock<CameraManager>(cameraManager).getMediaDownloadPath.mockResolvedValue({
sign: true,
endpoint: 'foo',
});
const signError = new Error('sign-error');
vi.mocked(homeAssistantSignPath).mockRejectedValue(signError);
expect(downloadMedia(createHASS(), cameraManager, media)).rejects.toThrow(
/Could not sign media URL for download/,
);
});
it('should download media', async () => {
const cameraManager = createCameraManager();
mock<CameraManager>(cameraManager).getMediaDownloadPath.mockResolvedValue({
sign: true,
endpoint: 'foo',
});
vi.mocked(homeAssistantSignPath).mockResolvedValue('http://foo/signed-url');
const windowSpy = vi.spyOn(window, 'open').mockReturnValue(null);
await downloadMedia(createHASS(), cameraManager, media);
expect(windowSpy).toBeCalledWith('http://foo/signed-url', '_blank');
});
});
describe('downloadURL', () => {
afterEach(() => {
vi.resetAllMocks();
global.window.location = mock<Location>();
});
it('should download same origin via link', async () => {
const location: Location & { origin: string } = mock<Location>();
location.origin = 'http://foo';
global.window.location = location;
const link = document.createElement('a');
link.click = vi.fn();
link.setAttribute = vi.fn();
vi.spyOn(document, 'createElement').mockReturnValue(link);
downloadURL('http://foo/url.mp4');
expect(link.href).toBe('http://foo/url.mp4');
expect(link.setAttribute).toBeCalledWith('download', 'download');
expect(link.click).toBeCalled();
});
it('should download in apps via window.open', async () => {
// Set the origin to the same.
const location: Location & { origin: string } = mock<Location>();
location.origin = 'http://foo';
global.window.location = location;
vi.stubGlobal('navigator', {
userAgent: 'Home Assistant/2023.3.0-3260 (Android 13; Pixel 7 Pro)',
});
const windowSpy = vi.spyOn(window, 'open').mockReturnValue(null);
downloadURL('http://foo/url.mp4');
expect(windowSpy).toBeCalledWith('http://foo/url.mp4', '_blank');
});
});
+17
View File
@@ -1067,6 +1067,23 @@ describe('MenuButtonController', () => {
});
});
it('should have screenshot button', () => {
const buttons = calculateButtons(controller, {
currentMediaLoadedInfo: createMediaLoadedInfo({
player: mock<FrigateCardMediaPlayer>(),
}),
});
expect(buttons).toContainEqual({
icon: 'mdi:monitor-screenshot',
enabled: false,
priority: 50,
type: 'custom:frigate-card-menu-icon',
title: 'Screenshot',
tap_action: { action: 'fire-dom-event', frigate_card_action: 'screenshot' },
});
});
it('should handle dynamic buttons', () => {
const button: MenuButton = {
...dynamicButton,
+95
View File
@@ -0,0 +1,95 @@
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { generateScreenshotTitle, screenshotMedia } from '../../src/utils/screenshot';
import { MediaQueriesResults } from '../../src/view/media-queries-results';
import { View } from '../../src/view/view';
import { TestViewMedia, createView } from '../test-utils';
// @vitest-environment jsdom
describe('screenshotMedia', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('should not screenshot without context', () => {
const video = document.createElement('video');
const canvas = document.createElement('canvas');
const getContext = vi.fn().mockReturnValue(null);
canvas.getContext = getContext;
vi.spyOn(document, 'createElement').mockReturnValue(canvas);
expect(screenshotMedia(video)).toBeNull();
});
it('should screenshot', () => {
const video = document.createElement('video');
const canvas = document.createElement('canvas');
const getContext = vi.fn().mockReturnValue(mock<CanvasRenderingContext2D>());
canvas.getContext = getContext;
canvas.toDataURL = vi.fn().mockReturnValue('data:image/jpeg;base64');
vi.spyOn(document, 'createElement').mockReturnValue(canvas);
expect(screenshotMedia(video)).toBe('data:image/jpeg;base64');
});
});
describe('generateScreenshotTitle', () => {
beforeAll(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2023-06-13T21:54:01'));
});
afterAll(() => {
vi.useRealTimers();
});
it('should get title without view', () => {
expect(generateScreenshotTitle()).toBe('screenshot.jpg');
});
it('should get title for live view', () => {
expect(generateScreenshotTitle(new View({ view: 'live', camera: 'camera-1' }))).toBe(
'live-camera-1-2023-06-13-21-54-01.jpg',
);
});
it('should get title for image view', () => {
expect(
generateScreenshotTitle(new View({ view: 'image', camera: 'camera-1' })),
).toBe('image-camera-1-2023-06-13-21-54-01.jpg');
});
it('should get title for media viewer view with id', () => {
const media = new TestViewMedia(
'id1',
new Date('2023-06-16T18:52'),
'clip',
'camera-1',
);
const view = createView({
view: 'media',
camera: 'camera-1',
queryResults: new MediaQueriesResults([media], 0),
});
expect(generateScreenshotTitle(view)).toBe('media-camera-1-id1.jpg');
});
it('should get title for media viewer view without id', () => {
const media = new TestViewMedia(
null,
new Date('2023-06-16T18:52'),
'clip',
'camera-1',
);
const view = createView({
view: 'media',
camera: 'camera-1',
queryResults: new MediaQueriesResults([media], 0),
});
expect(generateScreenshotTitle(view)).toBe('media-camera-1.jpg');
});
});