Merge branch 'release-0.11.0' of https://github.com/blakeblackshear/frigate into sub_label_filter

This commit is contained in:
Nick Mowen 2022-05-13 05:50:01 -06:00
commit c943cb9f1d
12 changed files with 465 additions and 272 deletions

View File

@ -247,3 +247,16 @@ HTTP Live Streaming Video on Demand URL for the specified event. Can be viewed i
### `GET /vod/<camera>/start/<start-timestamp>/end/<end-timestamp>/index.m3u8` ### `GET /vod/<camera>/start/<start-timestamp>/end/<end-timestamp>/index.m3u8`
HTTP Live Streaming Video on Demand URL for the camera with the specified time range. Can be viewed in an application like VLC. HTTP Live Streaming Video on Demand URL for the camera with the specified time range. Can be viewed in an application like VLC.
### `GET /api/<camera_name>/recordings/summary`
Hourly summary of recordings data for a camera.
### `GET /api/<camera_name>/recordings`
Get recording segment details for the given timestamp range.
| param | Type | Description |
| -------- | ---- | ------------------------------------- |
| `after` | int | Unix timestamp for beginning of range |
| `before` | int | Unix timestamp for end of range |

121
docs/package-lock.json generated
View File

@ -8,8 +8,8 @@
"name": "docs", "name": "docs",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@docusaurus/core": "^2.0.0-beta.15", "@docusaurus/core": "^2.0.0-beta.20",
"@docusaurus/preset-classic": "^2.0.0-beta.15", "@docusaurus/preset-classic": "^2.0.0-beta.20",
"@mdx-js/react": "^1.6.22", "@mdx-js/react": "^1.6.22",
"clsx": "^1.1.1", "clsx": "^1.1.1",
"raw-loader": "^4.0.2", "raw-loader": "^4.0.2",
@ -3453,9 +3453,9 @@
} }
}, },
"node_modules/ansi-regex": { "node_modules/ansi-regex": {
"version": "4.1.0", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz",
"integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==", "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==",
"engines": { "engines": {
"node": ">=6" "node": ">=6"
} }
@ -3563,9 +3563,9 @@
} }
}, },
"node_modules/async": { "node_modules/async": {
"version": "2.6.3", "version": "2.6.4",
"resolved": "https://registry.npmjs.org/async/-/async-2.6.3.tgz", "resolved": "https://registry.npmjs.org/async/-/async-2.6.4.tgz",
"integrity": "sha512-zflvls11DCy+dQWzTW2dzuilv8Z5X/pjfmZOWba6TNIVDm+2UDaJmXSOXlasHKfNBs8oo3M0aT50fDEWfKZjXg==", "integrity": "sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==",
"dependencies": { "dependencies": {
"lodash": "^4.17.14" "lodash": "^4.17.14"
} }
@ -3877,6 +3877,15 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/bindings": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
"optional": true,
"dependencies": {
"file-uri-to-path": "1.0.0"
}
},
"node_modules/bluebird": { "node_modules/bluebird": {
"version": "3.7.2", "version": "3.7.2",
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz",
@ -6182,6 +6191,12 @@
"webpack": "^4.0.0 || ^5.0.0" "webpack": "^4.0.0 || ^5.0.0"
} }
}, },
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
"optional": true
},
"node_modules/filesize": { "node_modules/filesize": {
"version": "6.1.0", "version": "6.1.0",
"resolved": "https://registry.npmjs.org/filesize/-/filesize-6.1.0.tgz", "resolved": "https://registry.npmjs.org/filesize/-/filesize-6.1.0.tgz",
@ -6272,9 +6287,9 @@
} }
}, },
"node_modules/follow-redirects": { "node_modules/follow-redirects": {
"version": "1.14.7", "version": "1.15.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.7.tgz", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.0.tgz",
"integrity": "sha512-+hbxoLbFMbRKDwohX8GkTataGqO6Jb7jGwpAlwgy2bIz25XtRm7KEzJM76R1WiNT5SwZkX4Y75SwBolkpmE7iQ==", "integrity": "sha512-aExlJShTV4qOUOL7yF1U5tvLCB0xQuudbf6toyYA0E/acBNw71mvjFTnLaRp50aQaYocMR0a/RMMBIHeZnGyjQ==",
"funding": [ "funding": [
{ {
"type": "individual", "type": "individual",
@ -8909,9 +8924,9 @@
} }
}, },
"node_modules/minimist": { "node_modules/minimist": {
"version": "1.2.5", "version": "1.2.6",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz",
"integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==" "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q=="
}, },
"node_modules/mixin-deep": { "node_modules/mixin-deep": {
"version": "1.3.2", "version": "1.3.2",
@ -8974,6 +8989,12 @@
"resolved": "https://registry.npmjs.org/multicast-dns-service-types/-/multicast-dns-service-types-1.1.0.tgz", "resolved": "https://registry.npmjs.org/multicast-dns-service-types/-/multicast-dns-service-types-1.1.0.tgz",
"integrity": "sha1-iZ8R2WhuXgXLkbNdXw5jt3PPyQE=" "integrity": "sha1-iZ8R2WhuXgXLkbNdXw5jt3PPyQE="
}, },
"node_modules/nan": {
"version": "2.15.0",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.15.0.tgz",
"integrity": "sha512-8ZtvEnA2c5aYCZYd1cvgdnU6cqwixRoYg70xPLWUws5ORTa/lnw+u4amixRS/Ac5U5mQVgp9pnlSUnbNWFaWZQ==",
"optional": true
},
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.2.0.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.2.0.tgz",
@ -10396,9 +10417,12 @@
} }
}, },
"node_modules/prismjs": { "node_modules/prismjs": {
"version": "1.25.0", "version": "1.28.0",
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.25.0.tgz", "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.28.0.tgz",
"integrity": "sha512-WCjJHl1KEWbnkQom1+SzftbtXMKQoezOCYs5rECqMN+jP+apI7ftoflyqigqzopSO3hMhTEb0mFClA8lkolgEg==" "integrity": "sha512-8aaXdYvl1F7iC7Xm1spqSaY/OJBpYW3v+KJ+F17iYxvdc8sfjW194COK5wVhMZX45tGteiBQgdvD/nhxcRwylw==",
"engines": {
"node": ">=6"
}
}, },
"node_modules/process-nextick-args": { "node_modules/process-nextick-args": {
"version": "2.0.1", "version": "2.0.1",
@ -13394,9 +13418,9 @@
} }
}, },
"node_modules/url-parse": { "node_modules/url-parse": {
"version": "1.5.3", "version": "1.5.10",
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.3.tgz", "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz",
"integrity": "sha512-IIORyIQD9rvj0A4CLWsHkBBJuNqWpFQe224b6j9t/ABmquIS0qDU2pY6kl6AuOrL5OkCXHMCFNe1jBcuAggjvQ==", "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
"dependencies": { "dependencies": {
"querystringify": "^2.1.1", "querystringify": "^2.1.1",
"requires-port": "^1.0.0" "requires-port": "^1.0.0"
@ -17139,9 +17163,9 @@
"integrity": "sha512-1APHAyr3+PCamwNw3bXCPp4HFLONZt/yIH0sZp0/469KWNTEy+qN5jQ3GVX6DMZ1UXAi34yVwtTeaG/HpBuuzw==" "integrity": "sha512-1APHAyr3+PCamwNw3bXCPp4HFLONZt/yIH0sZp0/469KWNTEy+qN5jQ3GVX6DMZ1UXAi34yVwtTeaG/HpBuuzw=="
}, },
"ansi-regex": { "ansi-regex": {
"version": "4.1.0", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz",
"integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==" "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g=="
}, },
"ansi-styles": { "ansi-styles": {
"version": "3.2.1", "version": "3.2.1",
@ -17219,9 +17243,9 @@
"integrity": "sha1-WWZ/QfrdTyDMvCu5a41Pf3jsA2c=" "integrity": "sha1-WWZ/QfrdTyDMvCu5a41Pf3jsA2c="
}, },
"async": { "async": {
"version": "2.6.3", "version": "2.6.4",
"resolved": "https://registry.npmjs.org/async/-/async-2.6.3.tgz", "resolved": "https://registry.npmjs.org/async/-/async-2.6.4.tgz",
"integrity": "sha512-zflvls11DCy+dQWzTW2dzuilv8Z5X/pjfmZOWba6TNIVDm+2UDaJmXSOXlasHKfNBs8oo3M0aT50fDEWfKZjXg==", "integrity": "sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==",
"requires": { "requires": {
"lodash": "^4.17.14" "lodash": "^4.17.14"
} }
@ -17456,6 +17480,15 @@
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
"integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==" "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA=="
}, },
"bindings": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
"optional": true,
"requires": {
"file-uri-to-path": "1.0.0"
}
},
"bluebird": { "bluebird": {
"version": "3.7.2", "version": "3.7.2",
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz",
@ -19221,6 +19254,12 @@
"schema-utils": "^3.0.0" "schema-utils": "^3.0.0"
} }
}, },
"file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
"optional": true
},
"filesize": { "filesize": {
"version": "6.1.0", "version": "6.1.0",
"resolved": "https://registry.npmjs.org/filesize/-/filesize-6.1.0.tgz", "resolved": "https://registry.npmjs.org/filesize/-/filesize-6.1.0.tgz",
@ -19292,9 +19331,9 @@
} }
}, },
"follow-redirects": { "follow-redirects": {
"version": "1.14.7", "version": "1.15.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.7.tgz", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.0.tgz",
"integrity": "sha512-+hbxoLbFMbRKDwohX8GkTataGqO6Jb7jGwpAlwgy2bIz25XtRm7KEzJM76R1WiNT5SwZkX4Y75SwBolkpmE7iQ==" "integrity": "sha512-aExlJShTV4qOUOL7yF1U5tvLCB0xQuudbf6toyYA0E/acBNw71mvjFTnLaRp50aQaYocMR0a/RMMBIHeZnGyjQ=="
}, },
"for-in": { "for-in": {
"version": "1.0.2", "version": "1.0.2",
@ -21270,9 +21309,9 @@
} }
}, },
"minimist": { "minimist": {
"version": "1.2.5", "version": "1.2.6",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz",
"integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==" "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q=="
}, },
"mixin-deep": { "mixin-deep": {
"version": "1.3.2", "version": "1.3.2",
@ -21325,6 +21364,12 @@
"resolved": "https://registry.npmjs.org/multicast-dns-service-types/-/multicast-dns-service-types-1.1.0.tgz", "resolved": "https://registry.npmjs.org/multicast-dns-service-types/-/multicast-dns-service-types-1.1.0.tgz",
"integrity": "sha1-iZ8R2WhuXgXLkbNdXw5jt3PPyQE=" "integrity": "sha1-iZ8R2WhuXgXLkbNdXw5jt3PPyQE="
}, },
"nan": {
"version": "2.15.0",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.15.0.tgz",
"integrity": "sha512-8ZtvEnA2c5aYCZYd1cvgdnU6cqwixRoYg70xPLWUws5ORTa/lnw+u4amixRS/Ac5U5mQVgp9pnlSUnbNWFaWZQ==",
"optional": true
},
"nanoid": { "nanoid": {
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.2.0.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.2.0.tgz",
@ -22285,9 +22330,9 @@
"requires": {} "requires": {}
}, },
"prismjs": { "prismjs": {
"version": "1.25.0", "version": "1.28.0",
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.25.0.tgz", "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.28.0.tgz",
"integrity": "sha512-WCjJHl1KEWbnkQom1+SzftbtXMKQoezOCYs5rECqMN+jP+apI7ftoflyqigqzopSO3hMhTEb0mFClA8lkolgEg==" "integrity": "sha512-8aaXdYvl1F7iC7Xm1spqSaY/OJBpYW3v+KJ+F17iYxvdc8sfjW194COK5wVhMZX45tGteiBQgdvD/nhxcRwylw=="
}, },
"process-nextick-args": { "process-nextick-args": {
"version": "2.0.1", "version": "2.0.1",
@ -24571,9 +24616,9 @@
} }
}, },
"url-parse": { "url-parse": {
"version": "1.5.3", "version": "1.5.10",
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.3.tgz", "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz",
"integrity": "sha512-IIORyIQD9rvj0A4CLWsHkBBJuNqWpFQe224b6j9t/ABmquIS0qDU2pY6kl6AuOrL5OkCXHMCFNe1jBcuAggjvQ==", "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
"requires": { "requires": {
"querystringify": "^2.1.1", "querystringify": "^2.1.1",
"requires-port": "^1.0.0" "requires-port": "^1.0.0"

View File

@ -12,8 +12,8 @@
"clear": "docusaurus clear" "clear": "docusaurus clear"
}, },
"dependencies": { "dependencies": {
"@docusaurus/core": "^2.0.0-beta.15", "@docusaurus/core": "^2.0.0-beta.20",
"@docusaurus/preset-classic": "^2.0.0-beta.15", "@docusaurus/preset-classic": "^2.0.0-beta.20",
"@mdx-js/react": "^1.6.22", "@mdx-js/react": "^1.6.22",
"clsx": "^1.1.1", "clsx": "^1.1.1",
"raw-loader": "^4.0.2", "raw-loader": "^4.0.2",

View File

@ -136,12 +136,14 @@ def set_retain(id):
@bp.route("/events/<id>/plus", methods=("POST",)) @bp.route("/events/<id>/plus", methods=("POST",))
def send_to_plus(id): def send_to_plus(id):
if current_app.plus_api.is_active(): if not current_app.plus_api.is_active():
message = "PLUS_API_KEY environment variable is not set"
logger.error(message)
return make_response( return make_response(
jsonify( jsonify(
{ {
"success": False, "success": False,
"message": "PLUS_API_KEY environment variable is not set", "message": message,
} }
), ),
400, 400,
@ -150,14 +152,14 @@ def send_to_plus(id):
try: try:
event = Event.get(Event.id == id) event = Event.get(Event.id == id)
except DoesNotExist: except DoesNotExist:
return make_response( message = f"Event {id} not found"
jsonify({"success": False, "message": "Event" + id + " not found"}), 404 logger.error(message)
) return make_response(jsonify({"success": False, "message": message}), 404)
if event.plus_id: if event.plus_id:
return make_response( message = "Already submitted to plus"
jsonify({"success": False, "message": "Already submitted to plus"}), 400 logger.error(message)
) return make_response(jsonify({"success": False, "message": message}), 400)
# load clean.png # load clean.png
try: try:
@ -175,6 +177,7 @@ def send_to_plus(id):
try: try:
plus_id = current_app.plus_api.upload_image(image, event.camera) plus_id = current_app.plus_api.upload_image(image, event.camera)
except Exception as ex: except Exception as ex:
logger.exception(ex)
return make_response( return make_response(
jsonify({"success": False, "message": str(ex)}), jsonify({"success": False, "message": str(ex)}),
400, 400,
@ -501,6 +504,21 @@ def events():
clauses = [] clauses = []
excluded_fields = [] excluded_fields = []
selected_columns = [
Event.id,
Event.camera,
Event.label,
Event.zones,
Event.start_time,
Event.end_time,
Event.has_clip,
Event.has_snapshot,
Event.plus_id,
Event.retain_indefinitely,
Event.sub_label,
Event.top_score,
]
if camera != "all": if camera != "all":
clauses.append((Event.camera == camera)) clauses.append((Event.camera == camera))
@ -527,12 +545,14 @@ def events():
if not include_thumbnails: if not include_thumbnails:
excluded_fields.append(Event.thumbnail) excluded_fields.append(Event.thumbnail)
else:
selected_columns.append(Event.thumbnail)
if len(clauses) == 0: if len(clauses) == 0:
clauses.append((True)) clauses.append((True))
events = ( events = (
Event.select() Event.select(*selected_columns)
.where(reduce(operator.and_, clauses)) .where(reduce(operator.and_, clauses))
.order_by(Event.start_time.desc()) .order_by(Event.start_time.desc())
.limit(limit) .limit(limit)
@ -638,122 +658,100 @@ def latest_frame(camera_name):
return "Camera named {} not found".format(camera_name), 404 return "Camera named {} not found".format(camera_name), 404
# return hourly summary for recordings of camera
@bp.route("/<camera_name>/recordings/summary")
def recordings_summary(camera_name):
recording_groups = (
Recordings.select(
fn.strftime(
"%Y-%m-%d %H",
fn.datetime(Recordings.start_time, "unixepoch", "localtime"),
).alias("hour"),
fn.SUM(Recordings.duration).alias("duration"),
fn.SUM(Recordings.motion).alias("motion"),
fn.SUM(Recordings.objects).alias("objects"),
)
.where(Recordings.camera == camera_name)
.group_by(
fn.strftime(
"%Y-%m-%d %H",
fn.datetime(Recordings.start_time, "unixepoch", "localtime"),
)
)
.order_by(
fn.strftime(
"%Y-%m-%d H",
fn.datetime(Recordings.start_time, "unixepoch", "localtime"),
).desc()
)
)
event_groups = (
Event.select(
fn.strftime(
"%Y-%m-%d %H", fn.datetime(Event.start_time, "unixepoch", "localtime")
).alias("hour"),
fn.COUNT(Event.id).alias("count"),
)
.where(Event.camera == camera_name, Event.has_clip)
.group_by(
fn.strftime(
"%Y-%m-%d %H", fn.datetime(Event.start_time, "unixepoch", "localtime")
),
)
.objects()
)
event_map = {g.hour: g.count for g in event_groups}
days = {}
for recording_group in recording_groups.objects():
parts = recording_group.hour.split()
hour = parts[1]
day = parts[0]
events_count = event_map.get(recording_group.hour, 0)
hour_data = {
"hour": hour,
"events": events_count,
"motion": recording_group.motion,
"objects": recording_group.objects,
"duration": round(recording_group.duration),
}
if day not in days:
days[day] = {"events": events_count, "hours": [hour_data], "day": day}
else:
days[day]["events"] += events_count
days[day]["hours"].append(hour_data)
return jsonify(list(days.values()))
# return hour of recordings data for camera
@bp.route("/<camera_name>/recordings") @bp.route("/<camera_name>/recordings")
def recordings(camera_name): def recordings(camera_name):
dates = OrderedDict() after = request.args.get(
"after", type=float, default=(datetime.now() - timedelta(hours=1)).timestamp()
)
before = request.args.get("before", type=float, default=datetime.now().timestamp())
# Retrieve all recordings for this camera
recordings = ( recordings = (
Recordings.select() Recordings.select(
.where(Recordings.camera == camera_name) Recordings.id,
.order_by(Recordings.start_time.asc()) Recordings.start_time,
Recordings.end_time,
Recordings.motion,
Recordings.objects,
)
.where(
Recordings.camera == camera_name,
Recordings.end_time >= after,
Recordings.start_time <= before,
)
.order_by(Recordings.start_time)
) )
last_end = 0 return jsonify([e for e in recordings.dicts()])
recording: Recordings
for recording in recordings:
date = datetime.fromtimestamp(recording.start_time)
key = date.strftime("%Y-%m-%d")
hour = date.strftime("%H")
# Create Day Record
if key not in dates:
dates[key] = OrderedDict()
# Create Hour Record
if hour not in dates[key]:
dates[key][hour] = {"delay": {}, "events": []}
# Check for delay
the_hour = datetime.strptime(f"{key} {hour}", "%Y-%m-%d %H").timestamp()
# diff current recording start time and the greater of the previous end time or top of the hour
diff = recording.start_time - max(last_end, the_hour)
# Determine seconds into recording
seconds = 0
if datetime.fromtimestamp(last_end).strftime("%H") == hour:
seconds = int(last_end - the_hour)
# Determine the delay
delay = min(int(diff), 3600 - seconds)
if delay > 1:
# Add an offset for any delay greater than a second
dates[key][hour]["delay"][seconds] = delay
last_end = recording.end_time
# Packing intervals to return all events with same label and overlapping times as one row.
# See: https://blogs.solidq.com/en/sqlserver/packing-intervals/
events = Event.raw(
"""WITH C1 AS
(
SELECT id, label, camera, top_score, start_time AS ts, +1 AS type, 1 AS sub
FROM event
WHERE camera = ?
UNION ALL
SELECT id, label, camera, top_score, end_time + 15 AS ts, -1 AS type, 0 AS sub
FROM event
WHERE camera = ?
),
C2 AS
(
SELECT C1.*,
SUM(type) OVER(PARTITION BY label ORDER BY ts, type DESC
ROWS BETWEEN UNBOUNDED PRECEDING
AND CURRENT ROW) - sub AS cnt
FROM C1
),
C3 AS
(
SELECT id, label, camera, top_score, ts,
(ROW_NUMBER() OVER(PARTITION BY label ORDER BY ts) - 1) / 2 + 1
AS grpnum
FROM C2
WHERE cnt = 0
)
SELECT id, label, camera, top_score, start_time, end_time
FROM event
WHERE camera = ? AND end_time IS NULL
UNION ALL
SELECT MIN(id) as id, label, camera, MAX(top_score) as top_score, MIN(ts) AS start_time, max(ts) AS end_time
FROM C3
GROUP BY label, grpnum
ORDER BY start_time;""",
camera_name,
camera_name,
camera_name,
)
event: Event
for event in events:
date = datetime.fromtimestamp(event.start_time)
key = date.strftime("%Y-%m-%d")
hour = date.strftime("%H")
if key in dates and hour in dates[key]:
dates[key][hour]["events"].append(
model_to_dict(
event,
exclude=[
Event.false_positive,
Event.zones,
Event.thumbnail,
Event.has_clip,
Event.has_snapshot,
],
)
)
return jsonify(
[
{
"date": date,
"events": sum([len(value["events"]) for value in hours.values()]),
"recordings": [
{"hour": hour, "delay": value["delay"], "events": value["events"]}
for hour, value in hours.items()
],
}
for date, hours in dates.items()
]
)
@bp.route("/<camera>/start/<int:start_ts>/end/<int:end_ts>/clip.mp4") @bp.route("/<camera>/start/<int:start_ts>/end/<int:end_ts>/clip.mp4")

View File

@ -44,6 +44,8 @@ class PlusApi:
raise Exception("Plus API not activated") raise Exception("Plus API not activated")
parts = self.key.split(":") parts = self.key.split(":")
r = requests.get(f"{self.host}/v1/auth/token", auth=(parts[0], parts[1])) r = requests.get(f"{self.host}/v1/auth/token", auth=(parts[0], parts[1]))
if not r.ok:
raise Exception("Unable to refresh API token")
self._token_data = r.json() self._token_data = r.json()
def _get_authorization_header(self) -> dict: def _get_authorization_header(self) -> dict:

View File

@ -9,7 +9,6 @@ import shutil
import string import string
import subprocess as sp import subprocess as sp
import threading import threading
import time
from collections import defaultdict from collections import defaultdict
from pathlib import Path from pathlib import Path
@ -459,7 +458,13 @@ class RecordingCleanup(threading.Thread):
deleted_recordings.add(recording.id) deleted_recordings.add(recording.id)
logger.debug(f"Expiring {len(deleted_recordings)} recordings") logger.debug(f"Expiring {len(deleted_recordings)} recordings")
Recordings.delete().where(Recordings.id << deleted_recordings).execute() # delete up to 100,000 at a time
max_deletes = 100000
deleted_recordings_list = list(deleted_recordings)
for i in range(0, len(deleted_recordings_list), max_deletes):
Recordings.delete().where(
Recordings.id << deleted_recordings_list[i : i + max_deletes]
).execute()
logger.debug(f"End camera: {camera}.") logger.debug(f"End camera: {camera}.")
@ -534,7 +539,12 @@ class RecordingCleanup(threading.Thread):
logger.debug( logger.debug(
f"Deleting {len(recordings_to_delete)} recordings with missing files" f"Deleting {len(recordings_to_delete)} recordings with missing files"
) )
Recordings.delete().where(Recordings.id << recordings_to_delete).execute() # delete up to 100,000 at a time
max_deletes = 100000
for i in range(0, len(recordings_to_delete), max_deletes):
Recordings.delete().where(
Recordings.id << recordings_to_delete[i : i + max_deletes]
).execute()
logger.debug("End sync recordings.") logger.debug("End sync recordings.")

View File

@ -0,0 +1,39 @@
"""Peewee migrations -- 011_update_indexes.py.
Some examples (model - class or model name)::
> Model = migrator.orm['model_name'] # Return model in current state by name
> migrator.sql(sql) # Run custom SQL
> migrator.python(func, *args, **kwargs) # Run python code
> migrator.create_model(Model) # Create a model (could be used as decorator)
> migrator.remove_model(model, cascade=True) # Remove a model
> migrator.add_fields(model, **fields) # Add fields to a model
> migrator.change_fields(model, **fields) # Change fields
> migrator.remove_fields(model, *field_names, cascade=True)
> migrator.rename_field(model, old_field_name, new_field_name)
> migrator.rename_table(model, new_table_name)
> migrator.add_index(model, *col_names, unique=False)
> migrator.drop_index(model, *col_names)
> migrator.add_not_null(model, *field_names)
> migrator.drop_not_null(model, *field_names)
> migrator.add_default(model, field_name, default)
"""
import peewee as pw
SQL = pw.SQL
def migrate(migrator, database, fake=False, **kwargs):
migrator.sql(
'CREATE INDEX "event_start_time_end_time" ON "event" ("start_time" DESC, "end_time" DESC)'
)
migrator.sql("DROP INDEX recordings_start_time_end_time")
migrator.sql(
'CREATE INDEX "recordings_end_time_start_time" ON "recordings" ("end_time" DESC, "start_time" DESC)'
)
def rollback(migrator, database, fake=False, **kwargs):
pass

View File

@ -14,7 +14,10 @@
}, },
"rules": { "rules": {
"indent": ["error", 2, { "SwitchCase": 1 }], "indent": ["error", 2, { "SwitchCase": 1 }],
"comma-dangle": ["error", { "objects": "always-multiline", "arrays": "always-multiline" }], "comma-dangle": [
"error",
{ "objects": "always-multiline", "arrays": "always-multiline", "imports": "always-multiline" }
],
"no-unused-vars": ["error", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }], "no-unused-vars": ["error", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }],
"no-console": "error" "no-console": "error"
}, },

View File

@ -31,7 +31,10 @@ export default function App() {
<AsyncRoute path="/cameras/:camera" getComponent={cameraComponent} /> <AsyncRoute path="/cameras/:camera" getComponent={cameraComponent} />
<AsyncRoute path="/birdseye" getComponent={Routes.getBirdseye} /> <AsyncRoute path="/birdseye" getComponent={Routes.getBirdseye} />
<AsyncRoute path="/events" getComponent={Routes.getEvents} /> <AsyncRoute path="/events" getComponent={Routes.getEvents} />
<AsyncRoute path="/recording/:camera/:date?/:hour?/:seconds?" getComponent={Routes.getRecording} /> <AsyncRoute
path="/recording/:camera/:date?/:hour?/:minute?/:second?"
getComponent={Routes.getRecording}
/>
<AsyncRoute path="/debug" getComponent={Routes.getDebug} /> <AsyncRoute path="/debug" getComponent={Routes.getDebug} />
<AsyncRoute path="/styleguide" getComponent={Routes.getStyleGuide} /> <AsyncRoute path="/styleguide" getComponent={Routes.getStyleGuide} />
<Cameras default path="/" /> <Cameras default path="/" />

View File

@ -10,7 +10,7 @@ export function ApiProvider({ children, options }) {
return ( return (
<SWRConfig <SWRConfig
value={{ value={{
fetcher: (path) => axios.get(path).then((res) => res.data), fetcher: (path, params) => axios.get(path, { params }).then((res) => res.data),
...options, ...options,
}} }}
> >

View File

@ -1,59 +1,39 @@
import { h } from 'preact'; import { h } from 'preact';
import { useState } from 'preact/hooks'; import { useState, useMemo } from 'preact/hooks';
import { import {
differenceInSeconds, getUnixTime,
fromUnixTime, fromUnixTime,
format, format,
parseISO, parseISO,
startOfHour, intervalToDuration,
differenceInMinutes, formatDuration,
differenceInHours endOfDay,
startOfDay,
isSameDay,
} from 'date-fns'; } from 'date-fns';
import ArrowDropdown from '../icons/ArrowDropdown'; import ArrowDropdown from '../icons/ArrowDropdown';
import ArrowDropup from '../icons/ArrowDropup'; import ArrowDropup from '../icons/ArrowDropup';
import Link from '../components/Link'; import Link from '../components/Link';
import ActivityIndicator from '../components/ActivityIndicator';
import Menu from '../icons/Menu'; import Menu from '../icons/Menu';
import MenuOpen from '../icons/MenuOpen'; import MenuOpen from '../icons/MenuOpen';
import { useApiHost } from '../api'; import { useApiHost } from '../api';
import useSWR from 'swr';
export default function RecordingPlaylist({ camera, recordings, selectedDate }) { export default function RecordingPlaylist({ camera, recordings, selectedDate }) {
const [active, setActive] = useState(true); const [active, setActive] = useState(true);
const toggle = () => setActive(!active); const toggle = () => setActive(!active);
const result = []; const result = [];
for (const recording of recordings.slice().reverse()) { for (const recording of recordings) {
const date = parseISO(recording.date); const date = parseISO(recording.day);
result.push( result.push(
<ExpandableList <ExpandableList
title={format(date, 'MMM d, yyyy')} title={format(date, 'MMM d, yyyy')}
events={recording.events} events={recording.events}
selected={recording.date === selectedDate} selected={isSameDay(date, selectedDate)}
> >
{recording.recordings <DayOfEvents camera={camera} day={recording.day} hours={recording.hours} />
.slice()
.reverse()
.map((item, i) => (
<div key={i} className="mb-2 w-full">
<div
className={`flex w-full text-md text-white px-8 py-2 mb-2 ${
i === 0 ? 'border-t border-white border-opacity-50' : ''
}`}
>
<div className="flex-1">
<Link href={`/recording/${camera}/${recording.date}/${item.hour}`} type="text">
{item.hour}:00
</Link>
</div>
<div className="flex-1 text-right">{item.events.length} Events</div>
</div>
{item.events
.slice()
.reverse()
.map((event) => (
<EventCard key={event.id} camera={camera} event={event} delay={item.delay} />
))}
</div>
))}
</ExpandableList> </ExpandableList>
); );
} }
@ -79,6 +59,71 @@ export default function RecordingPlaylist({ camera, recordings, selectedDate })
); );
} }
export function DayOfEvents({ camera, day, hours }) {
const date = parseISO(day);
const { data: events } = useSWR([
`events`,
{
before: getUnixTime(endOfDay(date)),
after: getUnixTime(startOfDay(date)),
camera,
has_clip: '1',
include_thumbnails: 0,
limit: 5000,
},
]);
// maps all the events under the keys for the hour by hour recordings view
const eventMap = useMemo(() => {
const eventMap = {};
for (const hour of hours) {
eventMap[`${day}-${hour.hour}`] = [];
}
if (!events) {
return eventMap;
}
for (const event of events) {
const key = format(fromUnixTime(event.start_time), 'yyyy-MM-dd-HH');
// if the hour of recordings is missing for the event start time, skip it
if (key in eventMap) {
eventMap[key].push(event);
}
}
return eventMap;
}, [events, day, hours]);
if (!events) {
return <ActivityIndicator />;
}
return (
<>
{hours.map((hour, i) => (
<div key={i} className="mb-2 w-full">
<div
className={`flex w-full text-md text-white px-8 py-2 mb-2 ${
i === 0 ? 'border-t border-white border-opacity-50' : ''
}`}
>
<div className="flex-1">
<Link href={`/recording/${camera}/${day}/${hour.hour}`} type="text">
{hour.hour}:00
</Link>
</div>
<div className="flex-1 text-right">{hour.events} Events</div>
</div>
{eventMap[`${day}-${hour.hour}`].map((event) => (
<EventCard key={event.id} camera={camera} event={event} />
))}
</div>
))}
</>
);
}
export function ExpandableList({ title, events = 0, children, selected = false }) { export function ExpandableList({ title, events = 0, children, selected = false }) {
const [active, setActive] = useState(selected); const [active, setActive] = useState(selected);
const toggle = () => setActive(!active); const toggle = () => setActive(!active);
@ -89,35 +134,26 @@ export function ExpandableList({ title, events = 0, children, selected = false }
<div className="flex-1 text-right mr-4">{events} Events</div> <div className="flex-1 text-right mr-4">{events} Events</div>
<div className="w-6 md:w-10 h-6 md:h-10">{active ? <ArrowDropup /> : <ArrowDropdown />}</div> <div className="w-6 md:w-10 h-6 md:h-10">{active ? <ArrowDropup /> : <ArrowDropdown />}</div>
</div> </div>
<div className={`bg-gray-800 bg-opacity-50 ${active ? '' : 'hidden'}`}>{children}</div> {/* Only render the child when expanded to lazy load events for the day */}
{active && <div className={`bg-gray-800 bg-opacity-50`}>{children}</div>}
</div> </div>
); );
} }
export function EventCard({ camera, event, delay }) { export function EventCard({ camera, event }) {
const apiHost = useApiHost(); const apiHost = useApiHost();
const start = fromUnixTime(event.start_time); const start = fromUnixTime(event.start_time);
const end = fromUnixTime(event.end_time);
let duration = 'In Progress'; let duration = 'In Progress';
if (event.end_time) { if (event.end_time) {
const end = fromUnixTime(event.end_time); duration = formatDuration(intervalToDuration({ start, end }));
const hours = differenceInHours(end, start);
const minutes = differenceInMinutes(end, start) - hours * 60;
const seconds = differenceInSeconds(end, start) - hours * 60 * 60 - minutes * 60;
duration = '';
if (hours) duration += `${hours}h `;
if (minutes) duration += `${minutes}m `;
duration += `${seconds}s`;
} }
const position = differenceInSeconds(start, startOfHour(start));
const offset = Object.entries(delay)
.map(([p, d]) => (position > p ? d : 0))
.reduce((p, c) => p + c, 0);
const seconds = Math.max(position - offset - 10, 0);
return ( return (
<Link className="" href={`/recording/${camera}/${format(start, 'yyyy-MM-dd')}/${format(start, 'HH')}/${seconds}`}> <Link className="" href={`/recording/${camera}/${format(start, 'yyyy-MM-dd/HH/mm/ss')}`}>
<div className="flex flex-row mb-2"> <div className="flex flex-row mb-2">
<div className="w-28 mr-4"> <div className="w-28 mr-4">
<img className="antialiased" src={`${apiHost}/api/events/${event.id}/thumbnail.jpg`} /> <img className="antialiased" loading="lazy" src={`${apiHost}/api/events/${event.id}/thumbnail.jpg`} />
</div> </div>
<div className="flex flex-row w-full border-b"> <div className="flex flex-row w-full border-b">
<div className="w-full text-gray-700 font-semibold relative pt-0"> <div className="w-full text-gray-700 font-semibold relative pt-0">

View File

@ -1,5 +1,6 @@
import { h } from 'preact'; import { h } from 'preact';
import { closestTo, format, parseISO } from 'date-fns'; import { parseISO, endOfHour, startOfHour, getUnixTime } from 'date-fns';
import { useEffect, useMemo } from 'preact/hooks';
import ActivityIndicator from '../components/ActivityIndicator'; import ActivityIndicator from '../components/ActivityIndicator';
import Heading from '../components/Heading'; import Heading from '../components/Heading';
import RecordingPlaylist from '../components/RecordingPlaylist'; import RecordingPlaylist from '../components/RecordingPlaylist';
@ -7,15 +8,106 @@ import VideoPlayer from '../components/VideoPlayer';
import { useApiHost } from '../api'; import { useApiHost } from '../api';
import useSWR from 'swr'; import useSWR from 'swr';
export default function Recording({ camera, date, hour, seconds }) { export default function Recording({ camera, date, hour = '00', minute = '00', second = '00' }) {
const apiHost = useApiHost(); const currentDate = useMemo(
const { data } = useSWR(`${camera}/recordings`); () => (date ? parseISO(`${date}T${hour || '00'}:${minute || '00'}:${second || '00'}`) : new Date()),
[date, hour, minute, second]
);
if (!data) { const apiHost = useApiHost();
const { data: recordingsSummary } = useSWR(`${camera}/recordings/summary`);
const recordingParams = {
before: getUnixTime(endOfHour(currentDate)),
after: getUnixTime(startOfHour(currentDate)),
};
const { data: recordings } = useSWR([`${camera}/recordings`, recordingParams]);
// calculates the seek seconds by adding up all the seconds in the segments prior to the playback time
const seekSeconds = useMemo(() => {
if (!recordings) {
return 0;
}
const currentUnix = getUnixTime(currentDate);
const hourStart = getUnixTime(startOfHour(currentDate));
let seekSeconds = 0;
recordings.every((segment) => {
// if the next segment is past the desired time, stop calculating
if (segment.start_time > currentUnix) {
return false;
}
// if the segment starts before the hour, skip the seconds before the hour
const start = segment.start_time < hourStart ? hourStart : segment.start_time;
// if the segment ends after the selected time, use the selected time for end
const end = segment.end_time > currentUnix ? currentUnix : segment.end_time;
seekSeconds += end - start;
return true;
});
return seekSeconds;
}, [recordings, currentDate]);
const playlist = useMemo(() => {
if (!recordingsSummary) {
return [];
}
const selectedDayRecordingData = recordingsSummary.find((s) => !date || s.day === date);
const [year, month, day] = selectedDayRecordingData.day.split('-');
return selectedDayRecordingData.hours
.map((h) => {
return {
name: h.hour,
description: `${camera} recording @ ${h.hour}:00.`,
sources: [
{
src: `${apiHost}/vod/${year}-${month}/${day}/${h.hour}/${camera}/index.m3u8`,
type: 'application/vnd.apple.mpegurl',
},
],
};
})
.reverse();
}, [apiHost, date, recordingsSummary, camera]);
const playlistIndex = useMemo(() => {
const index = playlist.findIndex((item) => item.name === hour);
if (index === -1) {
return 0;
}
return index;
}, [playlist, hour]);
useEffect(() => {
if (this.player) {
this.player.playlist(playlist);
}
}, [playlist]);
useEffect(() => {
if (this.player) {
this.player.playlist.currentItem(playlistIndex);
}
}, [playlistIndex]);
useEffect(() => {
if (this.player) {
// if the playlist has moved on to the next item, then reset
if (this.player.playlist.currentItem() !== playlistIndex) {
this.player.playlist.currentItem(playlistIndex);
}
this.player.currentTime(seekSeconds);
// try and play since the user is likely to have interacted with the dom
this.player.play();
}
}, [seekSeconds, playlistIndex]);
if (!recordingsSummary) {
return <ActivityIndicator />; return <ActivityIndicator />;
} }
if (data.length === 0) { if (recordingsSummary.length === 0) {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<Heading>{camera} Recordings</Heading> <Heading>{camera} Recordings</Heading>
@ -27,66 +119,18 @@ export default function Recording({ camera, date, hour, seconds }) {
); );
} }
const recordingDates = data.map((item) => item.date);
const selectedDate = closestTo(
date ? parseISO(date) : new Date(),
recordingDates.map((i) => parseISO(i))
);
const selectedKey = format(selectedDate, 'yyyy-MM-dd');
const [year, month, day] = selectedKey.split('-');
const playlist = [];
const hours = [];
for (const item of data) {
if (item.date === selectedKey) {
for (const recording of item.recordings) {
playlist.push({
name: `${selectedKey} ${recording.hour}:00`,
description: `${camera} recording @ ${recording.hour}:00.`,
sources: [
{
src: `${apiHost}/vod/${year}-${month}/${day}/${recording.hour}/${camera}/index.m3u8`,
type: 'application/vnd.apple.mpegurl',
},
],
});
hours.push(recording.hour);
}
}
}
const selectedHour = hours.indexOf(hour);
if (this.player) {
this.player.playlist([]);
this.player.playlist(playlist);
this.player.playlist.autoadvance(0);
if (selectedHour !== -1) {
this.player.playlist.currentItem(selectedHour);
if (seconds !== undefined) {
this.player.currentTime(seconds);
}
}
// Force playback rate to be correct
const playbackRate = this.player.playbackRate();
this.player.defaultPlaybackRate(playbackRate);
}
return ( return (
<div className="space-y-4 p-2 px-4"> <div className="space-y-4 p-2 px-4">
<Heading>{camera} Recordings</Heading> <Heading>{camera} Recordings</Heading>
<VideoPlayer <VideoPlayer
onReady={(player) => { onReady={(player) => {
player.on('ratechange', () => player.defaultPlaybackRate(player.playbackRate()));
if (player.playlist) { if (player.playlist) {
player.playlist(playlist); player.playlist(playlist);
player.playlist.autoadvance(0); player.playlist.autoadvance(0);
if (selectedHour !== -1) { player.playlist.currentItem(playlistIndex);
player.playlist.currentItem(selectedHour); player.currentTime(seekSeconds);
if (seconds !== undefined) {
player.currentTime(seconds);
}
}
this.player = player; this.player = player;
} }
}} }}
@ -94,7 +138,7 @@ export default function Recording({ camera, date, hour, seconds }) {
this.player = null; this.player = null;
}} }}
> >
<RecordingPlaylist camera={camera} recordings={data} selectedDate={selectedKey} selectedHour={hour} /> <RecordingPlaylist camera={camera} recordings={recordingsSummary} selectedDate={currentDate} />
</VideoPlayer> </VideoPlayer>
</div> </div>
); );