فهرست منبع

Initial Recordings UI

Jason Hunter 3 سال پیش
والد
کامیت
5461308d30

+ 59 - 1
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("/<camera_name>/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/<path:path>")
 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}],

+ 1 - 4
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/ {

+ 382 - 6
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",

+ 5 - 1
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",

+ 1 - 0
web/src/App.jsx

@@ -29,6 +29,7 @@ export default function App() {
                   <AsyncRoute path="/cameras/:camera" getComponent={Routes.getCamera} />
                   <AsyncRoute path="/events/:eventId" getComponent={Routes.getEvent} />
                   <AsyncRoute path="/events" getComponent={Routes.getEvents} />
+                  <AsyncRoute path="/recordings/:camera/:date?/:hour?" getComponent={Routes.getRecording} />
                   <AsyncRoute path="/debug" getComponent={Routes.getDebug} />
                   <AsyncRoute path="/styleguide" getComponent={Routes.getStyleGuide} />
                   <Cameras default path="/" />

+ 13 - 0
web/src/Sidebar.jsx

@@ -27,6 +27,19 @@ export default function Sidebar() {
           ) : null
         }
       </Match>
+      <Match path="/recordings/:camera/:date?/:hour?">
+        {({ matches }) =>
+          matches ? (
+            <Fragment>
+              <Separator />
+              {cameras.map((camera) => (
+                <Destination href={`/recordings/${camera}`} text={camera} />
+              ))}
+              <Separator />
+            </Fragment>
+          ) : null
+        }
+      </Match>
       <Destination href="/events" text="Events" />
       <Destination href="/debug" text="Debug" />
       <Separator />

+ 5 - 0
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);

+ 1 - 1
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'
   }`;
 

+ 26 - 0
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 (
+    <div className="min-w-20 min-h-20 md:min-w-32 md:min-h-32 p-1.5 mb-1 font-medium text-xs md:text-base">
+      <div className="w-20 md:w-32 flex-none rounded-lg text-center shadow-md">
+        <div className="block rounded-lg overflow-hidden text-center text-black">
+          <div className={`${bg} text-white py-0.5`}>{format(date, 'MMM yyyy')}</div>
+          <div className="pt-0.5 bg-white">
+            <span className="text-2xl md:text-5xl font-bold leading-tight">{format(date, 'd')}</span>
+          </div>
+          <div className="text-center bg-white pt-0.5">
+            <span className="md:text-sm">{format(date, 'EEEE')}</span>
+          </div>
+          <div className="pb-0.5 border-l border-r border-b border-white text-center bg-white hidden md:block">
+            <span className="md:text-xs leading-normal">
+              {hours} hrs, {events} events
+            </span>
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+}

+ 65 - 0
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',
+};

+ 51 - 0
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 (
+      <div style={style}>
+        <div data-vjs-player>
+          <video playsinline ref={(node) => (this.videoNode = node)} className="video-js" />
+          <div className="vjs-playlist" />
+        </div>
+      </div>
+    );
+  }
+}

+ 4 - 1
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(
     () => [
       {

+ 114 - 0
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 <ActivityIndicator />;
+  }
+
+  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(
+      <Link href={`/recordings/${camera}/${item.date}`}>
+        <Calendar
+          date={date}
+          hours={events.length}
+          events={events.reduce((a, b) => a + b)}
+          selected={isEqual(selectedDate, date)}
+        />
+      </Link>
+    );
+
+    if (item.date == selectedKey) {
+      for (const recording of item.recordings) {
+        buttons.push(
+          <Button href={`/recordings/${camera}/${item.date}/${recording.hour}`} type="text">
+            {recording.hour}:00
+          </Button>
+        );
+        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 (
+    <div className="space-y-4">
+      <Heading>{camera} Recordings</Heading>
+
+      <Carousel flickityRef={selectDate} options={{ pageDots: false }}>
+        {calendar}
+      </Carousel>
+
+      <VideoPlayer
+        date={selectedKey}
+        onReady={(player) => {
+          if (player.playlist) {
+            player.playlist(playlist);
+            player.playlist.autoadvance(0);
+            if (selectedHour !== -1) {
+              player.playlist.currentItem(selectedHour);
+            }
+            this.player = player;
+          }
+        }}
+      />
+
+      <Carousel flickityRef={selectHour} options={{ pageDots: false }}>
+        {buttons}
+      </Carousel>
+    </div>
+  );
+}

+ 5 - 0
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;