From 5461308d306f1edb8c8ab3d4dd8bdf72bf72e681 Mon Sep 17 00:00:00 2001 From: Jason Hunter Date: Fri, 28 May 2021 13:13:48 -0400 Subject: [PATCH] Initial Recordings UI --- frigate/http.py | 60 ++++- nginx/nginx.conf | 5 +- web/package-lock.json | 388 ++++++++++++++++++++++++++++- web/package.json | 6 +- web/src/App.jsx | 1 + web/src/Sidebar.jsx | 13 + web/src/api/index.jsx | 5 + web/src/components/Button.jsx | 2 +- web/src/components/Calendar.jsx | 26 ++ web/src/components/Carousel.jsx | 65 +++++ web/src/components/VideoPlayer.jsx | 51 ++++ web/src/routes/Cameras.jsx | 5 +- web/src/routes/Recording.jsx | 114 +++++++++ web/src/routes/index.js | 5 + 14 files changed, 732 insertions(+), 14 deletions(-) create mode 100644 web/src/components/Calendar.jsx create mode 100644 web/src/components/Carousel.jsx create mode 100644 web/src/components/VideoPlayer.jsx create mode 100644 web/src/routes/Recording.jsx diff --git a/frigate/http.py b/frigate/http.py index 8341c0dd4..380cf7c83 100644 --- a/frigate/http.py +++ b/frigate/http.py @@ -1,9 +1,11 @@ import base64 -import datetime +from collections import OrderedDict +from datetime import datetime, timedelta import json import glob import logging import os +import re import time from functools import reduce from pathlib import Path @@ -449,6 +451,57 @@ def latest_frame(camera_name): return "Camera named {} not found".format(camera_name), 404 +@bp.route("//recordings") +def recordings(camera_name): + files = glob.glob(f"{RECORD_DIR}/*/*/*/{camera_name}") + + if len(files) == 0: + return "No recordings found.", 404 + + files.sort() + + dates = OrderedDict() + for path in files: + search = re.search(r".+/(\d{4}[-]\d{2})/(\d{2})/(\d{2}).+", path) + if not search: + continue + date = f"{search.group(1)}-{search.group(2)}" + if date not in dates: + dates[date] = OrderedDict() + dates[date][search.group(3)] = 0 + + events = ( + Event.select( + fn.DATE(Event.start_time, "unixepoch", "localtime"), + fn.STRFTIME("%H", Event.start_time, "unixepoch", "localtime"), + fn.COUNT(Event.id), + ) + .where(Event.camera == camera_name) + .group_by( + fn.DATE(Event.start_time, "unixepoch", "localtime"), + fn.STRFTIME("%H", Event.start_time, "unixepoch", "localtime"), + ) + .tuples() + ) + + for date, hour, count in events: + key = date.strftime("%Y-%m-%d") + if key in dates and hour in dates[key]: + dates[key][hour] = count + + return jsonify( + [ + { + "date": date, + "recordings": [ + {"hour": hour, "events": events} for hour, events in hours.items() + ], + } + for date, hours in dates.items() + ] + ) + + @bp.route("/vod/") def vod(path): if not os.path.isdir(f"{RECORD_DIR}/{path}"): @@ -467,8 +520,13 @@ def vod(path): ) durations.append(duration) + # Should we cache? + parts = path.split("/", 4) + date = datetime.strptime(f"{parts[0]}-{parts[1]} {parts[2]}", "%Y-%m-%d %H") + return jsonify( { + "cache": datetime.now() - timedelta(hours=2) > date, "discontinuity": False, "durations": durations, "sequences": [{"clips": clips}], diff --git a/nginx/nginx.conf b/nginx/nginx.conf index 1641e09f8..5496efd07 100644 --- a/nginx/nginx.conf +++ b/nginx/nginx.conf @@ -39,12 +39,9 @@ http { vod_mode mapped; vod_max_mapping_response_size 1m; vod_upstream_location /api; - vod_last_modified 'Sun, 19 Nov 2000 08:52:00 GMT'; - vod_last_modified_types *; # vod caches vod_metadata_cache metadata_cache 512m; - vod_response_cache response_cache 128m; vod_mapping_cache mapping_cache 5m; # gzip manifests @@ -65,7 +62,7 @@ http { add_header Access-Control-Expose-Headers 'Server,range,Content-Length,Content-Range'; add_header Access-Control-Allow-Methods 'GET, HEAD, OPTIONS'; add_header Access-Control-Allow-Origin '*'; - expires 100d; + expires -1; } location /stream/ { diff --git a/web/package-lock.json b/web/package-lock.json index 678a75720..98e65710a 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -2873,7 +2873,6 @@ "version": "7.12.13", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.12.13.tgz", "integrity": "sha512-8+3UMPBrjFa/6TtKi/7sehPKqfAm4g6K+YQjyyFOLUTxzOngcRZTlAVY8sc2CORJYqdHQY8gRPHmn+qo15rCBw==", - "dev": true, "requires": { "regenerator-runtime": "^0.13.4" } @@ -4118,6 +4117,74 @@ "integrity": "sha512-GmVAWB+JuFKqSbzlofYK4qxk955gEv4Kd9/aj2hLOxneXMAm/J7OXcl5DlElS9tmkqwCcxGysSZGOrjzNvmjFQ==", "dev": true }, + "@videojs/http-streaming": { + "version": "2.6.4", + "resolved": "https://registry.npmjs.org/@videojs/http-streaming/-/http-streaming-2.6.4.tgz", + "integrity": "sha512-sFVE0MVXhawAkET8EgiUSMvDDv6u3uGidtO0BvNXG0/qKWlze/zEzhvLsyPU4HmLFRnffKeHK5RE2XpO5vHY8Q==", + "requires": { + "@babel/runtime": "^7.12.5", + "@videojs/vhs-utils": "^3.0.0", + "aes-decrypter": "3.1.2", + "global": "^4.4.0", + "m3u8-parser": "4.5.2", + "mpd-parser": "0.15.4", + "mux.js": "5.10.0", + "video.js": "^6 || ^7" + }, + "dependencies": { + "global": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/global/-/global-4.4.0.tgz", + "integrity": "sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==", + "requires": { + "min-document": "^2.19.0", + "process": "^0.11.10" + } + } + } + }, + "@videojs/vhs-utils": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@videojs/vhs-utils/-/vhs-utils-3.0.2.tgz", + "integrity": "sha512-r8Yas1/tNGsGRNoIaDJuiWiQgM0P2yaEnobgzw5JcBiEqxnS8EXoUm4QtKH7nJtnppZ1yqBx1agBZCvBMKXA2w==", + "requires": { + "@babel/runtime": "^7.12.5", + "global": "^4.4.0", + "url-toolkit": "^2.2.1" + }, + "dependencies": { + "global": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/global/-/global-4.4.0.tgz", + "integrity": "sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==", + "requires": { + "min-document": "^2.19.0", + "process": "^0.11.10" + } + } + } + }, + "@videojs/xhr": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@videojs/xhr/-/xhr-2.5.1.tgz", + "integrity": "sha512-wV9nGESHseSK+S9ePEru2+OJZ1jq/ZbbzniGQ4weAmTIepuBMSYPx5zrxxQA0E786T5ykpO8ts+LayV+3/oI2w==", + "requires": { + "@babel/runtime": "^7.5.5", + "global": "~4.4.0", + "is-function": "^1.0.1" + }, + "dependencies": { + "global": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/global/-/global-4.4.0.tgz", + "integrity": "sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==", + "requires": { + "min-document": "^2.19.0", + "process": "^0.11.10" + } + } + } + }, "abab": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.5.tgz", @@ -4163,6 +4230,28 @@ "integrity": "sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==", "dev": true }, + "aes-decrypter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/aes-decrypter/-/aes-decrypter-3.1.2.tgz", + "integrity": "sha512-42nRwfQuPRj9R1zqZBdoxnaAmnIFyDi0MNyTVhjdFOd8fifXKKRfwIHIZ6AMn1or4x5WONzjwRTbTWcsIQ0O4A==", + "requires": { + "@babel/runtime": "^7.12.5", + "@videojs/vhs-utils": "^3.0.0", + "global": "^4.4.0", + "pkcs7": "^1.0.4" + }, + "dependencies": { + "global": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/global/-/global-4.4.0.tgz", + "integrity": "sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==", + "requires": { + "min-document": "^2.19.0", + "process": "^0.11.10" + } + } + } + }, "ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -4763,6 +4852,14 @@ "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=", "dev": true }, + "chainsaw": { + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/chainsaw/-/chainsaw-0.0.9.tgz", + "integrity": "sha1-EaBRAtHEx4W20EFdM21aOhYSkT4=", + "requires": { + "traverse": ">=0.3.0 <0.4" + } + }, "chalk": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", @@ -5103,6 +5200,11 @@ "whatwg-url": "^8.0.0" } }, + "date-fns": { + "version": "2.21.3", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.21.3.tgz", + "integrity": "sha512-HeYdzCaFflc1i4tGbj7JKMjM4cKGYoyxwcIIkHzNgCkX8xXDNJDZXgDDVchIWpN4eQc3lH37WarduXFZJOtxfw==" + }, "debug": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz", @@ -5218,6 +5320,11 @@ "integrity": "sha512-9YLIBURXj4DJMFALxXw9K3Y3rwb5Fk0X5/8ipCzaN84+gKxoHK43tVKRNakCQbiEx07E8Uwhuq21BpUagFhZ8w==", "dev": true }, + "desandro-matches-selector": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/desandro-matches-selector/-/desandro-matches-selector-2.0.2.tgz", + "integrity": "sha1-cXvu1NwT59jzdi9wem1YpndCGOE=" + }, "detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", @@ -5271,6 +5378,11 @@ "integrity": "sha512-TvrjBckDy2c6v6RLxPv5QXOnU+SmF9nBII5621Ve5fu6Z/BDrENurBEvlC1f44lKEUVqOpK4w9E5Idc5/EgkLQ==", "dev": true }, + "dom-walk": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.2.tgz", + "integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w==" + }, "domconstants": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/domconstants/-/domconstants-0.1.2.tgz", @@ -5964,12 +6076,22 @@ "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", "dev": true }, + "estree-walker": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-0.6.1.tgz", + "integrity": "sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w==" + }, "esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true }, + "ev-emitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ev-emitter/-/ev-emitter-1.1.1.tgz", + "integrity": "sha512-ipiDYhdQSCZ4hSbX4rMW+XzNKMD1prg/sTvoVmSLkuQ1MVlwjJQQA+sW8tMYR3BLUr9KjodFV4pvzunvRhd33Q==" + }, "exec-sh": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/exec-sh/-/exec-sh-0.3.4.tgz", @@ -6259,6 +6381,14 @@ "path-exists": "^4.0.0" } }, + "fizzy-ui-utils": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/fizzy-ui-utils/-/fizzy-ui-utils-2.0.7.tgz", + "integrity": "sha512-CZXDVXQ1If3/r8s0T+v+qVeMshhfcuq0rqIFgJnrtd+Bu8GmDmqMjntjUePypVtjHXKJ6V4sw9zeyox34n9aCg==", + "requires": { + "desandro-matches-selector": "^2.0.0" + } + }, "flat-cache": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz", @@ -6275,6 +6405,19 @@ "integrity": "sha512-zAoAQiudy+r5SvnSw3KJy5os/oRJYHzrzja/tBDqrZtNhUw8bt6y8OBzMWcjWr+8liV8Eb6yOhw8WZ7VFZ5ZzA==", "dev": true }, + "flickity": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/flickity/-/flickity-2.2.2.tgz", + "integrity": "sha512-yiPMuP8tw/zN7ARgeSLZNvzK11GkzI2mp/zlYBsyttguSCROAqxj6wiN2sSfPfW3xMG3hcUHxWUXNQMlk/wYcg==", + "requires": { + "desandro-matches-selector": "^2.0.0", + "ev-emitter": "^1.1.1", + "fizzy-ui-utils": "^2.0.7", + "get-size": "^2.0.3", + "unidragger": "^2.3.0", + "unipointer": "^2.3.0" + } + }, "for-in": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz", @@ -6385,6 +6528,11 @@ "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", "dev": true }, + "get-size": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/get-size/-/get-size-2.0.3.tgz", + "integrity": "sha512-lXNzT/h/dTjTxRbm9BXb+SGxxzkm97h/PCIKtlN/CBCxxmkkIVV21udumMS93MuVTDX583gqc94v3RjuHmI+2Q==" + }, "get-stdin": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-8.0.0.tgz", @@ -6435,6 +6583,22 @@ "is-glob": "^4.0.1" } }, + "global": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/global/-/global-4.3.2.tgz", + "integrity": "sha1-52mJJopsdMOJCLEwWxD8DjlOnQ8=", + "requires": { + "min-document": "^2.19.0", + "process": "~0.5.1" + }, + "dependencies": { + "process": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/process/-/process-0.5.2.tgz", + "integrity": "sha1-FjjYqONML0QKkduVq5rrZ3/Bhc8=" + } + } + }, "globals": { "version": "11.12.0", "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", @@ -6537,6 +6701,14 @@ } } }, + "hashish": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/hashish/-/hashish-0.0.4.tgz", + "integrity": "sha1-bWC8b/r3Ebav1g5CbQd5iAFOZVQ=", + "requires": { + "traverse": ">=0.2.4" + } + }, "himalaya": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/himalaya/-/himalaya-1.1.0.tgz", @@ -6693,6 +6865,11 @@ "integrity": "sha1-8w9xbI4r00bHtn0985FVZqfAVgc=", "dev": true }, + "individual": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/individual/-/individual-2.0.0.tgz", + "integrity": "sha1-gzsJfa0jKU52EXqY+zjg2a1hu5c=" + }, "inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -6876,6 +7053,11 @@ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "dev": true }, + "is-function": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-function/-/is-function-1.0.2.tgz", + "integrity": "sha512-lw7DUp0aWXYg+CBCN+JKkcE0Q2RayZnSvnZBlwgxHBQhqt5pZNVy4Ri7H9GmmXkdu7LUthszM+Tor1u/2iBcpQ==" + }, "is-generator-fn": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", @@ -8596,6 +8778,11 @@ "object.assign": "^4.1.2" } }, + "keycode": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/keycode/-/keycode-2.2.0.tgz", + "integrity": "sha1-PQr1bce4uOXLqNCpfxByBO7CKwQ=" + }, "kind-of": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", @@ -8757,11 +8944,31 @@ "integrity": "sha1-wNjq82BZ9wV5bh40SBHPTEmNOiY=", "dev": true }, + "m3u8-parser": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/m3u8-parser/-/m3u8-parser-4.5.2.tgz", + "integrity": "sha512-sN/lu3TiRxmG2RFjZxo5c0/7Dr4RrEztl43jXrWwj5gFZ7vfa2iIxGfiPx485dm5QCazaIcKk+vNkUso8Aq0Ag==", + "requires": { + "@babel/runtime": "^7.12.5", + "@videojs/vhs-utils": "^3.0.0", + "global": "^4.4.0" + }, + "dependencies": { + "global": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/global/-/global-4.4.0.tgz", + "integrity": "sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==", + "requires": { + "min-document": "^2.19.0", + "process": "^0.11.10" + } + } + } + }, "magic-string": { "version": "0.25.7", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.7.tgz", "integrity": "sha512-4CrMT5DOHTDk4HYDlzmwu4FVCcIYI8gauveasrdCu2IKIFOJ3f0v/8MDGJCDL9oD2ppz/Av1b0Nj345H9M+XIA==", - "dev": true, "requires": { "sourcemap-codec": "^1.4.4" } @@ -8856,6 +9063,14 @@ "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", "dev": true }, + "min-document": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/min-document/-/min-document-2.19.0.tgz", + "integrity": "sha1-e9KC4/WELtKVu3SM3Z8f+iyCRoU=", + "requires": { + "dom-walk": "^0.1.0" + } + }, "min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -8916,6 +9131,28 @@ "integrity": "sha512-Xm9jdWvqFrlV0k965eY5AlCpWIIUBY2ExzGbEG+byMs+mZI4J7zvaUOLpQ8MTFgkpgyEnu4qUhuZT/Or3QeRiA==", "dev": true }, + "mpd-parser": { + "version": "0.15.4", + "resolved": "https://registry.npmjs.org/mpd-parser/-/mpd-parser-0.15.4.tgz", + "integrity": "sha512-YcOclxKc5gnT87UQYwRoPJpWOFvQORwN+bXYmTWCJ4U2pCSS7jjtPrIhoOLHFAyekj48CHTX4hjGBV/VSNsUsg==", + "requires": { + "@babel/runtime": "^7.12.5", + "@videojs/vhs-utils": "^3.0.0", + "global": "^4.4.0", + "xmldom": "^0.4.0" + }, + "dependencies": { + "global": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/global/-/global-4.4.0.tgz", + "integrity": "sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==", + "requires": { + "min-document": "^2.19.0", + "process": "^0.11.10" + } + } + } + }, "ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", @@ -8935,6 +9172,14 @@ "minimatch": "^3.0.4" } }, + "mux.js": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/mux.js/-/mux.js-5.10.0.tgz", + "integrity": "sha512-kLzvYsHYBwNa+ckkmpxWV3eImwntJbrwd1KbN4WR0hLe+dK/KB82aCuC0fQzAI2hkjYszdlSGsAWFgYdiFBUuA==", + "requires": { + "@babel/runtime": "^7.11.2" + } + }, "nanoid": { "version": "3.1.20", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.1.20.tgz", @@ -9381,6 +9626,14 @@ "node-modules-regexp": "^1.0.0" } }, + "pkcs7": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/pkcs7/-/pkcs7-1.0.4.tgz", + "integrity": "sha512-afRERtHn54AlwaF2/+LFszyAANTCggGilmcmILUzEjvs3XgFZT+xE6+QWQcAGmu4xajy+Xtj7acLOPdx5/eXWQ==", + "requires": { + "@babel/runtime": "^7.5.5" + } + }, "pkg-dir": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-2.0.0.tgz", @@ -9743,6 +9996,11 @@ "integrity": "sha1-t+PqQkNaTJsnWdmeDyAesZWALuE=", "dev": true }, + "process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha1-czIwDoQBYb2j5podHZGn1LwW8YI=" + }, "progress": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", @@ -9971,8 +10229,7 @@ "regenerator-runtime": { "version": "0.13.7", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz", - "integrity": "sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew==", - "dev": true + "integrity": "sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew==" }, "regenerator-transform": { "version": "0.14.5", @@ -10046,6 +10303,14 @@ } } }, + "remove": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/remove/-/remove-0.1.5.tgz", + "integrity": "sha1-CV/9gn1lyfQa2X0z5BanWBEHmVU=", + "requires": { + "seq": ">= 0.3.5" + } + }, "remove-trailing-separator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz", @@ -10230,6 +10495,23 @@ } } }, + "rollup-plugin-replace": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/rollup-plugin-replace/-/rollup-plugin-replace-2.2.0.tgz", + "integrity": "sha512-/5bxtUPkDHyBJAKketb4NfaeZjL5yLZdeUihSfbF2PQMz+rSTEb8ARKoOl3UBT4m7/X+QOXJo3sLTcq+yMMYTA==", + "requires": { + "magic-string": "^0.25.2", + "rollup-pluginutils": "^2.6.0" + } + }, + "rollup-pluginutils": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/rollup-pluginutils/-/rollup-pluginutils-2.8.2.tgz", + "integrity": "sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ==", + "requires": { + "estree-walker": "^0.6.1" + } + }, "rsvp": { "version": "4.8.5", "resolved": "https://registry.npmjs.org/rsvp/-/rsvp-4.8.5.tgz", @@ -10242,12 +10524,28 @@ "integrity": "sha512-zb/1OuZ6flOlH6tQyMPUrE3x3Ulxjlo9WIVXR4yVYi4H9UXQaeIsPbLn2R3O3vQCnDKkAl2qHiuocKKX4Tz/Sw==", "dev": true }, + "rust-result": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/rust-result/-/rust-result-1.0.0.tgz", + "integrity": "sha1-NMdbLm3Dn+WHXlveyFteD5FTb3I=", + "requires": { + "individual": "^2.0.0" + } + }, "safe-buffer": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "dev": true }, + "safe-json-parse": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/safe-json-parse/-/safe-json-parse-4.0.0.tgz", + "integrity": "sha1-fA9XjPzNEtM6ccDgVBPi7KFx6qw=", + "requires": { + "rust-result": "^1.0.0" + } + }, "safe-regex": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz", @@ -10490,6 +10788,15 @@ "lru-cache": "^6.0.0" } }, + "seq": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/seq/-/seq-0.3.5.tgz", + "integrity": "sha1-rgKvOkJHk9jMvyEtaRdODFTf/jg=", + "requires": { + "chainsaw": ">=0.0.7 <0.1", + "hashish": ">=0.0.2 <0.1" + } + }, "set-blocking": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", @@ -10809,8 +11116,7 @@ "sourcemap-codec": { "version": "1.4.8", "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", - "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==", - "dev": true + "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==" }, "spdx-correct": { "version": "3.1.1", @@ -11291,6 +11597,11 @@ "punycode": "^2.1.1" } }, + "traverse": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz", + "integrity": "sha1-cXuPIgzAu3tE5AUUwisui7xw2Lk=" + }, "ts-morph": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-9.1.0.tgz", @@ -11478,6 +11789,14 @@ "integrity": "sha512-PqSoPh/pWetQ2phoj5RLiaqIk4kCNwoV3CI+LfGmWLKI3rE3kl1h59XpX2BjgDrmbxD9ARtQobPGU1SguCYuQg==", "dev": true }, + "unidragger": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/unidragger/-/unidragger-2.3.1.tgz", + "integrity": "sha512-u+IgG7AG0MXJTKcdzAIYxCm+W5FcnA9M28203Awl6jIcE3/+9OtEyUX4Wv64y7XNKEVRKPot52IV4V6x7FlF5Q==", + "requires": { + "unipointer": "^2.3.0" + } + }, "union-value": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/union-value/-/union-value-1.0.1.tgz", @@ -11490,6 +11809,14 @@ "set-value": "^2.0.1" } }, + "unipointer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/unipointer/-/unipointer-2.3.0.tgz", + "integrity": "sha512-m85sAoELCZhogI1owtJV3Dva7GxkHk2lI7A0otw3o0OwCuC/Q9gi7ehddigEYIAYbhkqNdri+dU1QQkrcBvirQ==", + "requires": { + "ev-emitter": "^1.0.1" + } + }, "uniq": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/uniq/-/uniq-1.0.1.tgz", @@ -11557,6 +11884,11 @@ "integrity": "sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI=", "dev": true }, + "url-toolkit": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/url-toolkit/-/url-toolkit-2.2.2.tgz", + "integrity": "sha512-l25w6Sy+Iy3/IbogunxhWwljPaDnqpiKvrQRoLBm6DfISco7NyRIS7Zf6+Oxhy1T8kHxWdwLND7ZZba6NjXMug==" + }, "use": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz", @@ -11631,6 +11963,45 @@ "extsprintf": "^1.2.0" } }, + "video.js": { + "version": "7.11.8", + "resolved": "https://registry.npmjs.org/video.js/-/video.js-7.11.8.tgz", + "integrity": "sha512-iQmNYB+pdgu8b45Za1AKSa5J7uDyHIqfJy+picw4voKfjErXK/BEvs+A3f99Ck7SCZU4cmMmX/s17AwaaNs+1w==", + "requires": { + "@babel/runtime": "^7.9.2", + "@videojs/http-streaming": "2.6.4", + "@videojs/xhr": "2.5.1", + "global": "4.3.2", + "keycode": "^2.2.0", + "remove": "^0.1.5", + "rollup-plugin-replace": "^2.2.0", + "safe-json-parse": "4.0.0", + "videojs-font": "3.2.0", + "videojs-vtt.js": "^0.15.2" + } + }, + "videojs-font": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/videojs-font/-/videojs-font-3.2.0.tgz", + "integrity": "sha512-g8vHMKK2/JGorSfqAZQUmYYNnXmfec4MLhwtEFS+mMs2IDY398GLysy6BH6K+aS1KMNu/xWZ8Sue/X/mdQPliA==" + }, + "videojs-playlist": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/videojs-playlist/-/videojs-playlist-4.3.1.tgz", + "integrity": "sha512-fxI3T6mWHKaXRwTQyJeq5I0b8GM9Q4S/p92Aq7O1xAT+X8jYxYSIN15xi32a1F5adEGPRqct+yMl5MkXO9x9cQ==", + "requires": { + "global": "^4.3.2", + "video.js": "^6 || ^7" + } + }, + "videojs-vtt.js": { + "version": "0.15.3", + "resolved": "https://registry.npmjs.org/videojs-vtt.js/-/videojs-vtt.js-0.15.3.tgz", + "integrity": "sha512-5FvVsICuMRx6Hd7H/Y9s9GDeEtYcXQWzGMS+sl4UX3t/zoHp3y+isSfIPRochnTH7h+Bh1ILyC639xy9Z6kPag==", + "requires": { + "global": "^4.3.1" + } + }, "w3c-hr-time": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz", @@ -11784,6 +12155,11 @@ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "dev": true }, + "xmldom": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/xmldom/-/xmldom-0.4.0.tgz", + "integrity": "sha512-2E93k08T30Ugs+34HBSTQLVtpi6mCddaY8uO+pMNk1pqSjV5vElzn4mmh6KLxN3hki8rNcHSYzILoh3TEWORvA==" + }, "xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/web/package.json b/web/package.json index 68419e2ed..55d9edd08 100644 --- a/web/package.json +++ b/web/package.json @@ -11,11 +11,15 @@ "test": "jest" }, "dependencies": { + "date-fns": "^2.21.3", + "flickity": "^2.2.2", "idb-keyval": "^5.0.2", "immer": "^8.0.1", "preact": "^10.5.9", "preact-async-route": "^2.2.1", - "preact-router": "^3.2.1" + "preact-router": "^3.2.1", + "video.js": "^7.11.8", + "videojs-playlist": "^4.3.1" }, "devDependencies": { "@babel/eslint-parser": "^7.12.13", diff --git a/web/src/App.jsx b/web/src/App.jsx index 2f4d1dff8..85e466faf 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -29,6 +29,7 @@ export default function App() { + diff --git a/web/src/Sidebar.jsx b/web/src/Sidebar.jsx index 50dd7d3cc..cd37eda0c 100644 --- a/web/src/Sidebar.jsx +++ b/web/src/Sidebar.jsx @@ -27,6 +27,19 @@ export default function Sidebar() { ) : null } + + {({ matches }) => + matches ? ( + + + {cameras.map((camera) => ( + + ))} + + + ) : null + } + diff --git a/web/src/api/index.jsx b/web/src/api/index.jsx index 2e84a4159..c433c147e 100644 --- a/web/src/api/index.jsx +++ b/web/src/api/index.jsx @@ -110,6 +110,11 @@ export function useEvent(eventId, fetchId) { return useFetch(url, fetchId); } +export function useRecording(camera, fetchId) { + const url = `/api/${camera}/recordings`; + return useFetch(url, fetchId); +} + export function useConfig(searchParams, fetchId) { const url = `/api/config${searchParams ? `?${searchParams.toString()}` : ''}`; return useFetch(url, fetchId); diff --git a/web/src/components/Button.jsx b/web/src/components/Button.jsx index 673feaf1f..031010dae 100644 --- a/web/src/components/Button.jsx +++ b/web/src/components/Button.jsx @@ -66,7 +66,7 @@ export default function Button({ let classes = `whitespace-nowrap flex items-center space-x-1 ${className} ${ButtonTypes[type]} ${ ButtonColors[disabled ? 'disabled' : color][type] - } font-sans inline-flex font-bold uppercase text-xs px-2 py-2 rounded outline-none focus:outline-none ring-opacity-50 transition-shadow transition-colors ${ + } font-sans inline-flex font-bold uppercase text-xs px-1.5 md:px-2 py-2 rounded outline-none focus:outline-none ring-opacity-50 transition-shadow transition-colors ${ disabled ? 'cursor-not-allowed' : 'focus:ring-2 cursor-pointer' }`; diff --git a/web/src/components/Calendar.jsx b/web/src/components/Calendar.jsx new file mode 100644 index 000000000..e5f5d177c --- /dev/null +++ b/web/src/components/Calendar.jsx @@ -0,0 +1,26 @@ +import { h } from 'preact'; +import { format } from 'date-fns'; + +export default function Calendar({ date, hours = 0, events = 0, selected = false }) { + const bg = selected ? 'bg-blue-500 bg-opacity-80' : 'bg-gray-500'; + return ( +
+
+
+
{format(date, 'MMM yyyy')}
+
+ {format(date, 'd')} +
+
+ {format(date, 'EEEE')} +
+
+ + {hours} hrs, {events} events + +
+
+
+
+ ); +} diff --git a/web/src/components/Carousel.jsx b/web/src/components/Carousel.jsx new file mode 100644 index 000000000..ff0682c48 --- /dev/null +++ b/web/src/components/Carousel.jsx @@ -0,0 +1,65 @@ +import { h, Component } from 'preact'; +import Flickity from 'flickity'; +import 'flickity/css/flickity.css'; + +export default class Carousel extends Component { + constructor(props) { + super(props); + + this.carousel = null; + this.flkty = null; + } + + create() { + if (this.carousel) { + this.flkty = new Flickity(this.carousel, this.props.options); + + if (this.props.flickityRef) { + this.props.flickityRef(this.flkty); + } + } + } + + destroy() { + if (this.flkty) { + this.flkty.destroy(); + this.flkty = null; + this.carousel = null; + } + } + + componentWillUpdate() { + this.destroy(); + } + + componentDidUpdate() { + this.create(); + } + + componentWillUnmount() { + this.destroy(); + } + + componentDidMount() { + this.create(); + } + + render(props) { + return h( + this.props.elementType, + { + className: this.props.className, + ref: (c) => { + this.carousel = c; + }, + }, + this.props.children + ); + } +} + +Carousel.defaultProps = { + options: {}, + className: '', + elementType: 'div', +}; diff --git a/web/src/components/VideoPlayer.jsx b/web/src/components/VideoPlayer.jsx new file mode 100644 index 000000000..a2934d8d8 --- /dev/null +++ b/web/src/components/VideoPlayer.jsx @@ -0,0 +1,51 @@ +import { h, Component } from 'preact'; +import videojs from 'video.js'; +import 'videojs-playlist'; +import 'video.js/dist/video-js.css'; + +const defaultOptions = { + controls: true, + fluid: true, +}; + +export default class VideoPlayer extends Component { + componentDidMount() { + const { options, onReady = () => {} } = this.props; + const videoJsOptions = { + ...defaultOptions, + ...options, + }; + const self = this; + this.player = videojs(this.videoNode, videoJsOptions, function onPlayerReady() { + onReady(this); + this.on('error', () => { + console.error('VIDEOJS: ERROR: currentSources:', this.currentSources()); + }); + this.on('play', () => { + console.log('VIDEOJS: currentSources:', this.currentSources()); + }); + }); + } + + componentWillUnmount() { + if (this.player) { + this.player.dispose(); + } + } + + shouldComponentUpdate() { + return false; + } + + render() { + const { style } = this.props; + return ( +
+
+
+ ); + } +} diff --git a/web/src/routes/Cameras.jsx b/web/src/routes/Cameras.jsx index 60b9de159..121c937ba 100644 --- a/web/src/routes/Cameras.jsx +++ b/web/src/routes/Cameras.jsx @@ -28,7 +28,10 @@ function Camera({ name }) { const { payload: clipValue, send: sendClips } = useClipsState(name); const { payload: snapshotValue, send: sendSnapshots } = useSnapshotsState(name); const href = `/cameras/${name}`; - const buttons = useMemo(() => [{ name: 'Events', href: `/events?camera=${name}` }], [name]); + const buttons = useMemo(() => [ + { name: 'Events', href: `/events?camera=${name}` }, + { name: 'Recordings', href: `/recordings/${name}` } + ], [name]); const icons = useMemo( () => [ { diff --git a/web/src/routes/Recording.jsx b/web/src/routes/Recording.jsx new file mode 100644 index 000000000..5f7e88e84 --- /dev/null +++ b/web/src/routes/Recording.jsx @@ -0,0 +1,114 @@ +import { h } from 'preact'; +import { Link } from 'preact-router/match'; +import { closestTo, format, isEqual, parseISO } from 'date-fns'; +import ActivityIndicator from '../components/ActivityIndicator'; +import Button from '../components/Button'; +import Calendar from '../components/Calendar'; +import Carousel from '../components/Carousel'; +import Heading from '../components/Heading'; +import VideoPlayer from '../components/VideoPlayer'; +import { FetchStatus, useApiHost, useRecording } from '../api'; + +export default function Recording({ camera, date, hour }) { + const apiHost = useApiHost(); + const { data, status } = useRecording(camera); + + if (status !== FetchStatus.LOADED) { + return ; + } + + 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 calendar = []; + const buttons = []; + const playlist = []; + const hours = []; + + for (const item of data) { + const date = parseISO(item.date); + const events = item.recordings.map((i) => i.events); + calendar.push( + + a + b)} + selected={isEqual(selectedDate, date)} + /> + + ); + + if (item.date == selectedKey) { + for (const recording of item.recordings) { + buttons.push( + + ); + 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 !== undefined) { + this.player.playlist([]); + this.player.playlist(playlist); + this.player.playlist.autoadvance(0); + if (selectedHour !== -1) { + this.player.playlist.currentItem(selectedHour); + } + } + + const selectDate = (flkty) => { + flkty.select(recordingDates.indexOf(selectedKey), false, true); + }; + + const selectHour = (flkty) => { + flkty.select(selectedHour, false, true); + }; + + return ( +
+ {camera} Recordings + + + {calendar} + + + { + if (player.playlist) { + player.playlist(playlist); + player.playlist.autoadvance(0); + if (selectedHour !== -1) { + player.playlist.currentItem(selectedHour); + } + this.player = player; + } + }} + /> + + + {buttons} + +
+ ); +} diff --git a/web/src/routes/index.js b/web/src/routes/index.js index 1fdabe1ac..0b8a622b1 100644 --- a/web/src/routes/index.js +++ b/web/src/routes/index.js @@ -18,6 +18,11 @@ export async function getEvents(url, cb, props) { return module.default; } +export async function getRecording(url, cb, props) { + const module = await import('./Recording.jsx'); + return module.default; +} + export async function getDebug(url, cb, props) { const module = await import('./Debug.jsx'); return module.default;