Garbage collect segments.

This commit is contained in:
Dermot Duffy
2022-09-24 13:39:17 -07:00
parent a2e80d93a3
commit 5ec3cbac54
5 changed files with 79 additions and 8 deletions
+4 -3
View File
@@ -455,6 +455,10 @@ export class FrigateCardTimelineCore extends LitElement {
* @param properties * @param properties
*/ */
protected _timelineRangeChangeHandler(properties: TimelineRangeChange): void { protected _timelineRangeChangeHandler(properties: TimelineRangeChange): void {
if (this._pointerHeld) {
this._ignoreClick = true;
}
if ( if (
this._timeline && this._timeline &&
properties.byUser && properties.byUser &&
@@ -463,9 +467,6 @@ export class FrigateCardTimelineCore extends LitElement {
properties.event.additionalEvent !== 'pinchin' && properties.event.additionalEvent !== 'pinchin' &&
properties.event.additionalEvent !== 'pinchout' properties.event.additionalEvent !== 'pinchout'
) { ) {
if (this._pointerHeld) {
this._ignoreClick = true;
}
const targetTime = this._pointerHeld?.window const targetTime = this._pointerHeld?.window
? add(properties.start, { ? add(properties.start, {
-2
View File
@@ -1,6 +1,4 @@
// TODO: When a media viewer is first loaded the selected child won't work (because the underlying carousel has not yet rendered) // TODO: When a media viewer is first loaded the selected child won't work (because the underlying carousel has not yet rendered)
// TODO: delete segments if not in summary? is this actually necessary? could it create gaps in data? better off stopping access via summary?
// TODO: Make minitimeline configurable in the editor // TODO: Make minitimeline configurable in the editor
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
+16
View File
@@ -95,3 +95,19 @@ export const isHoverableDevice = (): boolean => window.matchMedia(
export const formatDateAndTime = (date: Date): string => { export const formatDateAndTime = (date: Date): string => {
return format(date, 'yyyy-MM-dd HH:mm'); return format(date, 'yyyy-MM-dd HH:mm');
} }
/**
* Run a function in idle periods. If idle callbacks are not supported (e.g.
* Safari) the callback is run immediately.
* @param func The function to call.
* @param timeout The maximum number of seconds to wait.
*/
export const runWhenIdleIfSupported = (func: () => void, timeout?: number): void => {
if (window.requestIdleCallback) {
window.requestIdleCallback(func, {
...(timeout && { timeout: timeout})
});
} else {
func();
}
}
+1 -1
View File
@@ -49,7 +49,7 @@ const recordingSummarySchema = z
.object({ .object({
day: z.preprocess((arg) => { day: z.preprocess((arg) => {
// Must provide the hour:minute:second on parsing or Javascript will // Must provide the hour:minute:second on parsing or Javascript will
// assume UTC midnight. // assume *UTC* midnight.
return typeof arg === 'string' ? new Date(`${arg}T00:00:00`) : arg; return typeof arg === 'string' ? new Date(`${arg}T00:00:00`) : arg;
}, z.date()), }, z.date()),
events: z.number(), events: z.number(),
+58 -2
View File
@@ -9,7 +9,7 @@ import {
FrigateEvent, FrigateEvent,
FrigateEvents, FrigateEvents,
} from '../types.js'; } from '../types.js';
import { errorToConsole } from '../utils/basic.js'; import { errorToConsole, runWhenIdleIfSupported } from '../utils/basic.js';
import { import {
FrigateGetEventsParameters, FrigateGetEventsParameters,
getEventsMultiple, getEventsMultiple,
@@ -19,6 +19,8 @@ import {
RecordingSummary, RecordingSummary,
} from './frigate.js'; } from './frigate.js';
import { dispatchFrigateCardErrorEvent } from '../components/message.js'; import { dispatchFrigateCardErrorEvent } from '../components/message.js';
import fromUnixTime from 'date-fns/fromUnixTime';
import { throttle } from 'lodash-es';
const RECORDING_SEGMENT_TOLERANCE = 60; const RECORDING_SEGMENT_TOLERANCE = 60;
const TIMELINE_DATA_MANAGER_MAX_AGE_SECONDS = 10; const TIMELINE_DATA_MANAGER_MAX_AGE_SECONDS = 10;
@@ -104,6 +106,15 @@ export class TimelineDataManager {
protected _cameras: Map<string, CameraConfig>; protected _cameras: Map<string, CameraConfig>;
protected _mediaType: TimelineMediaType; protected _mediaType: TimelineMediaType;
// Garbage collect segments at most once an hour.
protected _throttledSegmentGarbageCollector = throttle(
() => {
runWhenIdleIfSupported(this._garbageCollectSegments.bind(this));
},
60 * 60 * 1000,
{ trailing: true },
);
constructor(cameras: Map<string, CameraConfig>, mediaType: TimelineMediaType) { constructor(cameras: Map<string, CameraConfig>, mediaType: TimelineMediaType) {
this._cameras = cameras; this._cameras = cameras;
this._mediaType = mediaType; this._mediaType = mediaType;
@@ -131,10 +142,14 @@ export class TimelineDataManager {
): DataView<FrigateCardTimelineItem> { ): DataView<FrigateCardTimelineItem> {
return new DataView(this._dataset, { return new DataView(this._dataset, {
filter: (item: FrigateCardTimelineItem) => filter: (item: FrigateCardTimelineItem) =>
// Only return items for the given cameras.
!!item.group && !!item.group &&
cameraIDs.has(String(item.group)) && cameraIDs.has(String(item.group)) &&
// Don't return recordings if the user does not want them.
(showRecordings || item.type !== 'background') && (showRecordings || item.type !== 'background') &&
(mediaType === 'all' || // Don't return events that are the wrong media type.
(item.type === 'background' ||
mediaType === 'all' ||
(mediaType === 'clips' && !!item.event?.has_clip) || (mediaType === 'clips' && !!item.event?.has_clip) ||
(mediaType === 'snapshots' && !!item.event?.has_snapshot)), (mediaType === 'snapshots' && !!item.event?.has_snapshot)),
}); });
@@ -290,9 +305,50 @@ export class TimelineDataManager {
? [this._fetchRecordingSegments(hass, segmentStart, segmentEnd)] ? [this._fetchRecordingSegments(hass, segmentStart, segmentEnd)]
: []), : []),
]); ]);
this._throttledSegmentGarbageCollector();
return true; return true;
} }
/**
* Garbage collect recording segments that no longer feature in the summary.
*/
protected _garbageCollectSegments(): void {
if (!this._recordingSegments || !this._recordingSummary) {
return;
}
// Performance: _recordingSegments is potentially very large (e.g. 10K - 1M
// items) and each item must be examined, so care required here to stick to
// nothing worse than O(n) performance.
const getHourID = (cameraID: string, day: number, hour: number): string => {
return `${cameraID}/${day}/${hour}`;
};
const goodHours: Set<string> = new Set();
for (const cameraID of this._recordingSummary.keys()) {
for (const summaryDay of this._recordingSummary?.get(cameraID) ?? []) {
for (const summaryHour of summaryDay.hours) {
goodHours.add(getHourID(cameraID, summaryDay.day.getDate(), summaryHour.hour));
}
}
}
const deleteIDs: string[] = [];
this._recordingSegments.forEach((item, id) => {
const startDate = fromUnixTime(item.start / 1000);
const hourID = getHourID(item.cameraID, startDate.getDate(), startDate.getHours());
// ~O(1) lookup time for a JS set.
if (!goodHours.has(hourID)) {
deleteIDs.push(String(id));
}
});
this._recordingSegments.remove(deleteIDs);
this._compressRecordingSegmentsOntoTimeline();
}
/** /**
* Fetch recording segments for cameras. * Fetch recording segments for cameras.
* @param hass The HomeAssistant object. * @param hass The HomeAssistant object.