wip
This commit is contained in:
24
.devcontainer/devcontainer.json
Normal file
24
.devcontainer/devcontainer.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
|
||||||
|
// README at: https://github.com/devcontainers/templates/tree/main/src/debian
|
||||||
|
{
|
||||||
|
"features": {
|
||||||
|
"ghcr.io/devcontainers/features/go:1": {
|
||||||
|
"version": "1.26",
|
||||||
|
"golangciLintVersion": "2"
|
||||||
|
},
|
||||||
|
"ghcr.io/devcontainers/features/node:1": {
|
||||||
|
"version": "lts"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"name": "Debian",
|
||||||
|
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
|
||||||
|
"image": "mcr.microsoft.com/devcontainers/base:trixie"
|
||||||
|
// Features to add to the dev container. More info: https://containers.dev/features.
|
||||||
|
// "features": {},
|
||||||
|
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||||
|
// "forwardPorts": [],
|
||||||
|
// Configure tool-specific properties.
|
||||||
|
// "customizations": {},
|
||||||
|
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
|
||||||
|
// "remoteUser": "root"
|
||||||
|
}
|
||||||
5
.editorconfig
Normal file
5
.editorconfig
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*.json]
|
||||||
|
indent_style = tab
|
||||||
|
indent_size = 4
|
||||||
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
.env*
|
||||||
|
!.env.default
|
||||||
41
cctv.code-workspace
Normal file
41
cctv.code-workspace
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"folders": [
|
||||||
|
{
|
||||||
|
"name": "/",
|
||||||
|
"path": "."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "server",
|
||||||
|
"path": "server"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "web",
|
||||||
|
"path": "web"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"extensions": {
|
||||||
|
"recommendations": [
|
||||||
|
"golang.go",
|
||||||
|
"esbenp.prettier-vscode",
|
||||||
|
"editorconfig.editorconfig",
|
||||||
|
"svelte.svelte-vscode",
|
||||||
|
"dbaeumer.vscode-eslint",
|
||||||
|
"bradlc.vscode-tailwindcss"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"files.associations": {
|
||||||
|
"*.css": "tailwindcss"
|
||||||
|
},
|
||||||
|
"tailwindCSS.classAttributes": [
|
||||||
|
"class",
|
||||||
|
"className",
|
||||||
|
"ngClass",
|
||||||
|
"class:list",
|
||||||
|
"classes",
|
||||||
|
".*Class",
|
||||||
|
".*Classes"
|
||||||
|
],
|
||||||
|
"tailwindCSS.classFunctions": ["tw"]
|
||||||
|
}
|
||||||
|
}
|
||||||
4
server/.env.default
Normal file
4
server/.env.default
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
ENVIRONMENT=development
|
||||||
|
CCTV_PB_ENCRYPTION_KEY=_CCTV_POCKETBASE_ENCRYPTION_KEY_ # Note: length 32 as required
|
||||||
|
CCTV_PB_DATA_DIR=./pb_data
|
||||||
|
CCTV_EXTERNAL_WEB_APP=http://localhost:5173
|
||||||
2
server/.gitignore
vendored
Normal file
2
server/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
pb_data/
|
||||||
|
recordings/
|
||||||
39
server/.golangci.yml
Normal file
39
server/.golangci.yml
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
version: "2"
|
||||||
|
linters:
|
||||||
|
default: none
|
||||||
|
enable:
|
||||||
|
- asasalint
|
||||||
|
- errcheck
|
||||||
|
- govet
|
||||||
|
- ineffassign
|
||||||
|
- staticcheck
|
||||||
|
- unused
|
||||||
|
- errorlint
|
||||||
|
- exhaustive
|
||||||
|
- forbidigo
|
||||||
|
- forcetypeassert
|
||||||
|
- gochecknoglobals
|
||||||
|
- gosec
|
||||||
|
- nilerr
|
||||||
|
- nilnesserr
|
||||||
|
- nilnil
|
||||||
|
- predeclared
|
||||||
|
- reassign
|
||||||
|
- revive
|
||||||
|
- sloglint
|
||||||
|
- tagliatelle
|
||||||
|
- usestdlibvars
|
||||||
|
settings:
|
||||||
|
revive:
|
||||||
|
enable-default-rules: true
|
||||||
|
rules:
|
||||||
|
- name: exported
|
||||||
|
disabled: true
|
||||||
|
- name: package-comments
|
||||||
|
disabled: true
|
||||||
|
formatters:
|
||||||
|
enable:
|
||||||
|
- gofmt
|
||||||
|
- goimports
|
||||||
|
severity:
|
||||||
|
default: error
|
||||||
29
server/.vscode/settings.json
vendored
Normal file
29
server/.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"[go.mod]": {
|
||||||
|
"editor.defaultFormatter": "golang.go",
|
||||||
|
"editor.formatOnSave": true,
|
||||||
|
"editor.codeActionsOnSave": {
|
||||||
|
"source.organizeImports": "always"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"[go]": {
|
||||||
|
"editor.defaultFormatter": "golang.go",
|
||||||
|
"editor.formatOnSave": true,
|
||||||
|
"editor.codeActionsOnSave": {
|
||||||
|
"source.organizeImports": "always"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"go.lintTool": "golangci-lint-v2",
|
||||||
|
"go.lintFlags": [
|
||||||
|
"--path-mode=abs",
|
||||||
|
"--fast-only"
|
||||||
|
],
|
||||||
|
"go.formatTool": "custom",
|
||||||
|
"go.alternateTools": {
|
||||||
|
"customFormatter": "golangci-lint-v2"
|
||||||
|
},
|
||||||
|
"go.formatFlags": [
|
||||||
|
"fmt",
|
||||||
|
"--stdin"
|
||||||
|
]
|
||||||
|
}
|
||||||
41
server/api.go
Normal file
41
server/api.go
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
func registerAPI(se *core.ServeEvent) {
|
||||||
|
group := se.Router.Group("/api/cctv")
|
||||||
|
|
||||||
|
group.GET("/thumb/{streamId}", func(e *core.RequestEvent) error {
|
||||||
|
streamId := e.Request.PathValue("streamId")
|
||||||
|
if streamId == "" {
|
||||||
|
return e.BadRequestError("Missing stream ID", nil)
|
||||||
|
}
|
||||||
|
img, err := ingestService.GetThumbnail(e.Request.Context(), streamId)
|
||||||
|
if err != nil {
|
||||||
|
return e.InternalServerError("Failed to get thumbnail", err)
|
||||||
|
}
|
||||||
|
if img == nil {
|
||||||
|
return e.NotFoundError("Thumbnail not found", nil)
|
||||||
|
}
|
||||||
|
e.Response.Header().Set("Content-Type", "image/jpeg")
|
||||||
|
e.Response.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||||
|
_, err = io.Copy(e.Response, img)
|
||||||
|
if err != nil {
|
||||||
|
return e.InternalServerError("Failed to write thumbnail", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
group.GET("/live/{streamId}", func(e *core.RequestEvent) error {
|
||||||
|
streamId := e.Request.PathValue("streamId")
|
||||||
|
if streamId == "" {
|
||||||
|
return e.BadRequestError("Missing stream ID", nil)
|
||||||
|
}
|
||||||
|
ingestService.SubscribeLive(e.Request.Context(), streamId)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
968
server/collections.json
Normal file
968
server/collections.json
Normal file
@@ -0,0 +1,968 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "pbc_2279338944",
|
||||||
|
"listRule": "@request.auth.id != '' \u0026\u0026 recordRef = @request.auth.id \u0026\u0026 collectionRef = @request.auth.collectionId",
|
||||||
|
"viewRule": "@request.auth.id != '' \u0026\u0026 recordRef = @request.auth.id \u0026\u0026 collectionRef = @request.auth.collectionId",
|
||||||
|
"createRule": null,
|
||||||
|
"updateRule": null,
|
||||||
|
"deleteRule": null,
|
||||||
|
"name": "_mfas",
|
||||||
|
"type": "base",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "[a-z0-9]{15}",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text3208210256",
|
||||||
|
"max": 15,
|
||||||
|
"min": 15,
|
||||||
|
"name": "id",
|
||||||
|
"pattern": "^[a-z0-9]+$",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": true,
|
||||||
|
"required": true,
|
||||||
|
"system": true,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text455797646",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "collectionRef",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": true,
|
||||||
|
"system": true,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text127846527",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "recordRef",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": true,
|
||||||
|
"system": true,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text1582905952",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "method",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": true,
|
||||||
|
"system": true,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate2990389176",
|
||||||
|
"name": "created",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": false,
|
||||||
|
"presentable": false,
|
||||||
|
"system": true,
|
||||||
|
"type": "autodate"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate3332085495",
|
||||||
|
"name": "updated",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": true,
|
||||||
|
"presentable": false,
|
||||||
|
"system": true,
|
||||||
|
"type": "autodate"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"indexes": [
|
||||||
|
"CREATE INDEX `idx_mfas_collectionRef_recordRef` ON `_mfas` (collectionRef,recordRef)"
|
||||||
|
],
|
||||||
|
"created": "2026-04-03 11:46:26.426Z",
|
||||||
|
"updated": "2026-04-03 11:46:26.426Z",
|
||||||
|
"system": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "pbc_1638494021",
|
||||||
|
"listRule": "@request.auth.id != '' \u0026\u0026 recordRef = @request.auth.id \u0026\u0026 collectionRef = @request.auth.collectionId",
|
||||||
|
"viewRule": "@request.auth.id != '' \u0026\u0026 recordRef = @request.auth.id \u0026\u0026 collectionRef = @request.auth.collectionId",
|
||||||
|
"createRule": null,
|
||||||
|
"updateRule": null,
|
||||||
|
"deleteRule": null,
|
||||||
|
"name": "_otps",
|
||||||
|
"type": "base",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "[a-z0-9]{15}",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text3208210256",
|
||||||
|
"max": 15,
|
||||||
|
"min": 15,
|
||||||
|
"name": "id",
|
||||||
|
"pattern": "^[a-z0-9]+$",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": true,
|
||||||
|
"required": true,
|
||||||
|
"system": true,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text455797646",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "collectionRef",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": true,
|
||||||
|
"system": true,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text127846527",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "recordRef",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": true,
|
||||||
|
"system": true,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cost": 8,
|
||||||
|
"hidden": true,
|
||||||
|
"id": "password901924565",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "password",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"required": true,
|
||||||
|
"system": true,
|
||||||
|
"type": "password"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": true,
|
||||||
|
"id": "text3866985172",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "sentTo",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": true,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate2990389176",
|
||||||
|
"name": "created",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": false,
|
||||||
|
"presentable": false,
|
||||||
|
"system": true,
|
||||||
|
"type": "autodate"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate3332085495",
|
||||||
|
"name": "updated",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": true,
|
||||||
|
"presentable": false,
|
||||||
|
"system": true,
|
||||||
|
"type": "autodate"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"indexes": [
|
||||||
|
"CREATE INDEX `idx_otps_collectionRef_recordRef` ON `_otps` (collectionRef, recordRef)"
|
||||||
|
],
|
||||||
|
"created": "2026-04-03 11:46:26.429Z",
|
||||||
|
"updated": "2026-04-03 11:46:26.429Z",
|
||||||
|
"system": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "pbc_2281828961",
|
||||||
|
"listRule": "@request.auth.id != '' \u0026\u0026 recordRef = @request.auth.id \u0026\u0026 collectionRef = @request.auth.collectionId",
|
||||||
|
"viewRule": "@request.auth.id != '' \u0026\u0026 recordRef = @request.auth.id \u0026\u0026 collectionRef = @request.auth.collectionId",
|
||||||
|
"createRule": null,
|
||||||
|
"updateRule": null,
|
||||||
|
"deleteRule": "@request.auth.id != '' \u0026\u0026 recordRef = @request.auth.id \u0026\u0026 collectionRef = @request.auth.collectionId",
|
||||||
|
"name": "_externalAuths",
|
||||||
|
"type": "base",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "[a-z0-9]{15}",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text3208210256",
|
||||||
|
"max": 15,
|
||||||
|
"min": 15,
|
||||||
|
"name": "id",
|
||||||
|
"pattern": "^[a-z0-9]+$",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": true,
|
||||||
|
"required": true,
|
||||||
|
"system": true,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text455797646",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "collectionRef",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": true,
|
||||||
|
"system": true,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text127846527",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "recordRef",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": true,
|
||||||
|
"system": true,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text2462348188",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "provider",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": true,
|
||||||
|
"system": true,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text1044722854",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "providerId",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": true,
|
||||||
|
"system": true,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate2990389176",
|
||||||
|
"name": "created",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": false,
|
||||||
|
"presentable": false,
|
||||||
|
"system": true,
|
||||||
|
"type": "autodate"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate3332085495",
|
||||||
|
"name": "updated",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": true,
|
||||||
|
"presentable": false,
|
||||||
|
"system": true,
|
||||||
|
"type": "autodate"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"indexes": [
|
||||||
|
"CREATE UNIQUE INDEX `idx_externalAuths_record_provider` ON `_externalAuths` (collectionRef, recordRef, provider)",
|
||||||
|
"CREATE UNIQUE INDEX `idx_externalAuths_collection_provider` ON `_externalAuths` (collectionRef, provider, providerId)"
|
||||||
|
],
|
||||||
|
"created": "2026-04-03 11:46:26.431Z",
|
||||||
|
"updated": "2026-04-03 11:46:26.431Z",
|
||||||
|
"system": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "pbc_4275539003",
|
||||||
|
"listRule": "@request.auth.id != '' \u0026\u0026 recordRef = @request.auth.id \u0026\u0026 collectionRef = @request.auth.collectionId",
|
||||||
|
"viewRule": "@request.auth.id != '' \u0026\u0026 recordRef = @request.auth.id \u0026\u0026 collectionRef = @request.auth.collectionId",
|
||||||
|
"createRule": null,
|
||||||
|
"updateRule": null,
|
||||||
|
"deleteRule": "@request.auth.id != '' \u0026\u0026 recordRef = @request.auth.id \u0026\u0026 collectionRef = @request.auth.collectionId",
|
||||||
|
"name": "_authOrigins",
|
||||||
|
"type": "base",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "[a-z0-9]{15}",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text3208210256",
|
||||||
|
"max": 15,
|
||||||
|
"min": 15,
|
||||||
|
"name": "id",
|
||||||
|
"pattern": "^[a-z0-9]+$",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": true,
|
||||||
|
"required": true,
|
||||||
|
"system": true,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text455797646",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "collectionRef",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": true,
|
||||||
|
"system": true,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text127846527",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "recordRef",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": true,
|
||||||
|
"system": true,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text4228609354",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "fingerprint",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": true,
|
||||||
|
"system": true,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate2990389176",
|
||||||
|
"name": "created",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": false,
|
||||||
|
"presentable": false,
|
||||||
|
"system": true,
|
||||||
|
"type": "autodate"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate3332085495",
|
||||||
|
"name": "updated",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": true,
|
||||||
|
"presentable": false,
|
||||||
|
"system": true,
|
||||||
|
"type": "autodate"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"indexes": [
|
||||||
|
"CREATE UNIQUE INDEX `idx_authOrigins_unique_pairs` ON `_authOrigins` (collectionRef, recordRef, fingerprint)"
|
||||||
|
],
|
||||||
|
"created": "2026-04-03 11:46:26.435Z",
|
||||||
|
"updated": "2026-04-03 11:46:26.435Z",
|
||||||
|
"system": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "pbc_3142635823",
|
||||||
|
"listRule": null,
|
||||||
|
"viewRule": null,
|
||||||
|
"createRule": null,
|
||||||
|
"updateRule": null,
|
||||||
|
"deleteRule": null,
|
||||||
|
"name": "_superusers",
|
||||||
|
"type": "auth",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "[a-z0-9]{15}",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text3208210256",
|
||||||
|
"max": 15,
|
||||||
|
"min": 15,
|
||||||
|
"name": "id",
|
||||||
|
"pattern": "^[a-z0-9]+$",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": true,
|
||||||
|
"required": true,
|
||||||
|
"system": true,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cost": 0,
|
||||||
|
"hidden": true,
|
||||||
|
"id": "password901924565",
|
||||||
|
"max": 0,
|
||||||
|
"min": 8,
|
||||||
|
"name": "password",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"required": true,
|
||||||
|
"system": true,
|
||||||
|
"type": "password"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "[a-zA-Z0-9]{50}",
|
||||||
|
"hidden": true,
|
||||||
|
"id": "text2504183744",
|
||||||
|
"max": 60,
|
||||||
|
"min": 30,
|
||||||
|
"name": "tokenKey",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": true,
|
||||||
|
"system": true,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"exceptDomains": null,
|
||||||
|
"hidden": false,
|
||||||
|
"id": "email3885137012",
|
||||||
|
"name": "email",
|
||||||
|
"onlyDomains": null,
|
||||||
|
"presentable": false,
|
||||||
|
"required": true,
|
||||||
|
"system": true,
|
||||||
|
"type": "email"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "bool1547992806",
|
||||||
|
"name": "emailVisibility",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": true,
|
||||||
|
"type": "bool"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "bool256245529",
|
||||||
|
"name": "verified",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": true,
|
||||||
|
"type": "bool"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate2990389176",
|
||||||
|
"name": "created",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": false,
|
||||||
|
"presentable": false,
|
||||||
|
"system": true,
|
||||||
|
"type": "autodate"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate3332085495",
|
||||||
|
"name": "updated",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": true,
|
||||||
|
"presentable": false,
|
||||||
|
"system": true,
|
||||||
|
"type": "autodate"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"indexes": [
|
||||||
|
"CREATE UNIQUE INDEX `idx_tokenKey_pbc_3142635823` ON `_superusers` (`tokenKey`)",
|
||||||
|
"CREATE UNIQUE INDEX `idx_email_pbc_3142635823` ON `_superusers` (`email`) WHERE `email` != ''"
|
||||||
|
],
|
||||||
|
"created": "2026-04-03 11:46:26.437Z",
|
||||||
|
"updated": "2026-04-03 11:46:26.437Z",
|
||||||
|
"system": true,
|
||||||
|
"authRule": "",
|
||||||
|
"manageRule": null,
|
||||||
|
"authAlert": {
|
||||||
|
"enabled": true,
|
||||||
|
"emailTemplate": {
|
||||||
|
"subject": "Login from a new location",
|
||||||
|
"body": "\u003cp\u003eHello,\u003c/p\u003e\n\u003cp\u003eWe noticed a login to your {APP_NAME} account from a new location:\u003c/p\u003e\n\u003cp\u003e\u003cem\u003e{ALERT_INFO}\u003c/em\u003e\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eIf this wasn't you, you should immediately change your {APP_NAME} account password to revoke access from all other locations.\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003eIf this was you, you may disregard this email.\u003c/p\u003e\n\u003cp\u003e\n Thanks,\u003cbr/\u003e\n {APP_NAME} team\n\u003c/p\u003e"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"oauth2": {
|
||||||
|
"providers": [],
|
||||||
|
"mappedFields": {
|
||||||
|
"id": "",
|
||||||
|
"name": "",
|
||||||
|
"username": "",
|
||||||
|
"avatarURL": ""
|
||||||
|
},
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"passwordAuth": {
|
||||||
|
"enabled": true,
|
||||||
|
"identityFields": [
|
||||||
|
"email"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"mfa": {
|
||||||
|
"enabled": false,
|
||||||
|
"duration": 1800,
|
||||||
|
"rule": ""
|
||||||
|
},
|
||||||
|
"otp": {
|
||||||
|
"enabled": false,
|
||||||
|
"duration": 180,
|
||||||
|
"length": 8,
|
||||||
|
"emailTemplate": {
|
||||||
|
"subject": "OTP for {APP_NAME}",
|
||||||
|
"body": "\u003cp\u003eHello,\u003c/p\u003e\n\u003cp\u003eYour one-time password is: \u003cstrong\u003e{OTP}\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003e\u003ci\u003eIf you didn't ask for the one-time password, you can ignore this email.\u003c/i\u003e\u003c/p\u003e\n\u003cp\u003e\n Thanks,\u003cbr/\u003e\n {APP_NAME} team\n\u003c/p\u003e"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"authToken": {
|
||||||
|
"duration": 86400
|
||||||
|
},
|
||||||
|
"passwordResetToken": {
|
||||||
|
"duration": 1800
|
||||||
|
},
|
||||||
|
"emailChangeToken": {
|
||||||
|
"duration": 1800
|
||||||
|
},
|
||||||
|
"verificationToken": {
|
||||||
|
"duration": 259200
|
||||||
|
},
|
||||||
|
"fileToken": {
|
||||||
|
"duration": 180
|
||||||
|
},
|
||||||
|
"verificationTemplate": {
|
||||||
|
"subject": "Verify your {APP_NAME} email",
|
||||||
|
"body": "\u003cp\u003eHello,\u003c/p\u003e\n\u003cp\u003eThank you for joining us at {APP_NAME}.\u003c/p\u003e\n\u003cp\u003eClick on the button below to verify your email address.\u003c/p\u003e\n\u003cp\u003e\n \u003ca class=\"btn\" href=\"{APP_URL}/_/#/auth/confirm-verification/{TOKEN}\" target=\"_blank\" rel=\"noopener\"\u003eVerify\u003c/a\u003e\n\u003c/p\u003e\n\u003cp\u003e\n Thanks,\u003cbr/\u003e\n {APP_NAME} team\n\u003c/p\u003e"
|
||||||
|
},
|
||||||
|
"resetPasswordTemplate": {
|
||||||
|
"subject": "Reset your {APP_NAME} password",
|
||||||
|
"body": "\u003cp\u003eHello,\u003c/p\u003e\n\u003cp\u003eClick on the button below to reset your password.\u003c/p\u003e\n\u003cp\u003e\n \u003ca class=\"btn\" href=\"{APP_URL}/_/#/auth/confirm-password-reset/{TOKEN}\" target=\"_blank\" rel=\"noopener\"\u003eReset password\u003c/a\u003e\n\u003c/p\u003e\n\u003cp\u003e\u003ci\u003eIf you didn't ask to reset your password, you can ignore this email.\u003c/i\u003e\u003c/p\u003e\n\u003cp\u003e\n Thanks,\u003cbr/\u003e\n {APP_NAME} team\n\u003c/p\u003e"
|
||||||
|
},
|
||||||
|
"confirmEmailChangeTemplate": {
|
||||||
|
"subject": "Confirm your {APP_NAME} new email address",
|
||||||
|
"body": "\u003cp\u003eHello,\u003c/p\u003e\n\u003cp\u003eClick on the button below to confirm your new email address.\u003c/p\u003e\n\u003cp\u003e\n \u003ca class=\"btn\" href=\"{APP_URL}/_/#/auth/confirm-email-change/{TOKEN}\" target=\"_blank\" rel=\"noopener\"\u003eConfirm new email\u003c/a\u003e\n\u003c/p\u003e\n\u003cp\u003e\u003ci\u003eIf you didn't ask to change your email address, you can ignore this email.\u003c/i\u003e\u003c/p\u003e\n\u003cp\u003e\n Thanks,\u003cbr/\u003e\n {APP_NAME} team\n\u003c/p\u003e"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "pbc_1736455494",
|
||||||
|
"listRule": null,
|
||||||
|
"viewRule": null,
|
||||||
|
"createRule": null,
|
||||||
|
"updateRule": null,
|
||||||
|
"deleteRule": null,
|
||||||
|
"name": "users",
|
||||||
|
"type": "auth",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "[a-z0-9]{15}",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text3208210256",
|
||||||
|
"max": 15,
|
||||||
|
"min": 15,
|
||||||
|
"name": "id",
|
||||||
|
"pattern": "^[a-z0-9]+$",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": true,
|
||||||
|
"required": true,
|
||||||
|
"system": true,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cost": 0,
|
||||||
|
"hidden": true,
|
||||||
|
"id": "password901924565",
|
||||||
|
"max": 0,
|
||||||
|
"min": 8,
|
||||||
|
"name": "password",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"required": true,
|
||||||
|
"system": true,
|
||||||
|
"type": "password"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "[a-zA-Z0-9]{50}",
|
||||||
|
"hidden": true,
|
||||||
|
"id": "text2504183744",
|
||||||
|
"max": 60,
|
||||||
|
"min": 30,
|
||||||
|
"name": "tokenKey",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": true,
|
||||||
|
"system": true,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"exceptDomains": null,
|
||||||
|
"hidden": false,
|
||||||
|
"id": "email3885137012",
|
||||||
|
"name": "email",
|
||||||
|
"onlyDomains": null,
|
||||||
|
"presentable": false,
|
||||||
|
"required": true,
|
||||||
|
"system": true,
|
||||||
|
"type": "email"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "bool1547992806",
|
||||||
|
"name": "emailVisibility",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": true,
|
||||||
|
"type": "bool"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "bool256245529",
|
||||||
|
"name": "verified",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": true,
|
||||||
|
"type": "bool"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate2990389176",
|
||||||
|
"name": "created",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": false,
|
||||||
|
"presentable": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "autodate"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate3332085495",
|
||||||
|
"name": "updated",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": true,
|
||||||
|
"presentable": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "autodate"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"indexes": [
|
||||||
|
"CREATE UNIQUE INDEX `idx_tokenKey_pbc_1736455494` ON `users` (`tokenKey`)",
|
||||||
|
"CREATE UNIQUE INDEX `idx_email_pbc_1736455494` ON `users` (`email`) WHERE `email` != ''"
|
||||||
|
],
|
||||||
|
"created": "2026-04-03 11:46:26.465Z",
|
||||||
|
"updated": "2026-04-03 11:46:26.465Z",
|
||||||
|
"system": false,
|
||||||
|
"authRule": "",
|
||||||
|
"manageRule": null,
|
||||||
|
"authAlert": {
|
||||||
|
"enabled": true,
|
||||||
|
"emailTemplate": {
|
||||||
|
"subject": "Login from a new location",
|
||||||
|
"body": "\u003cp\u003eHello,\u003c/p\u003e\n\u003cp\u003eWe noticed a login to your {APP_NAME} account from a new location:\u003c/p\u003e\n\u003cp\u003e\u003cem\u003e{ALERT_INFO}\u003c/em\u003e\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eIf this wasn't you, you should immediately change your {APP_NAME} account password to revoke access from all other locations.\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003eIf this was you, you may disregard this email.\u003c/p\u003e\n\u003cp\u003e\n Thanks,\u003cbr/\u003e\n {APP_NAME} team\n\u003c/p\u003e"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"oauth2": {
|
||||||
|
"providers": [],
|
||||||
|
"mappedFields": {
|
||||||
|
"id": "",
|
||||||
|
"name": "",
|
||||||
|
"username": "",
|
||||||
|
"avatarURL": ""
|
||||||
|
},
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"passwordAuth": {
|
||||||
|
"enabled": true,
|
||||||
|
"identityFields": [
|
||||||
|
"email"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"mfa": {
|
||||||
|
"enabled": false,
|
||||||
|
"duration": 1800,
|
||||||
|
"rule": ""
|
||||||
|
},
|
||||||
|
"otp": {
|
||||||
|
"enabled": false,
|
||||||
|
"duration": 180,
|
||||||
|
"length": 8,
|
||||||
|
"emailTemplate": {
|
||||||
|
"subject": "OTP for {APP_NAME}",
|
||||||
|
"body": "\u003cp\u003eHello,\u003c/p\u003e\n\u003cp\u003eYour one-time password is: \u003cstrong\u003e{OTP}\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003e\u003ci\u003eIf you didn't ask for the one-time password, you can ignore this email.\u003c/i\u003e\u003c/p\u003e\n\u003cp\u003e\n Thanks,\u003cbr/\u003e\n {APP_NAME} team\n\u003c/p\u003e"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"authToken": {
|
||||||
|
"duration": 604800
|
||||||
|
},
|
||||||
|
"passwordResetToken": {
|
||||||
|
"duration": 1800
|
||||||
|
},
|
||||||
|
"emailChangeToken": {
|
||||||
|
"duration": 1800
|
||||||
|
},
|
||||||
|
"verificationToken": {
|
||||||
|
"duration": 259200
|
||||||
|
},
|
||||||
|
"fileToken": {
|
||||||
|
"duration": 180
|
||||||
|
},
|
||||||
|
"verificationTemplate": {
|
||||||
|
"subject": "Verify your {APP_NAME} email",
|
||||||
|
"body": "\u003cp\u003eHello,\u003c/p\u003e\n\u003cp\u003eThank you for joining us at {APP_NAME}.\u003c/p\u003e\n\u003cp\u003eClick on the button below to verify your email address.\u003c/p\u003e\n\u003cp\u003e\n \u003ca class=\"btn\" href=\"{APP_URL}/_/#/auth/confirm-verification/{TOKEN}\" target=\"_blank\" rel=\"noopener\"\u003eVerify\u003c/a\u003e\n\u003c/p\u003e\n\u003cp\u003e\n Thanks,\u003cbr/\u003e\n {APP_NAME} team\n\u003c/p\u003e"
|
||||||
|
},
|
||||||
|
"resetPasswordTemplate": {
|
||||||
|
"subject": "Reset your {APP_NAME} password",
|
||||||
|
"body": "\u003cp\u003eHello,\u003c/p\u003e\n\u003cp\u003eClick on the button below to reset your password.\u003c/p\u003e\n\u003cp\u003e\n \u003ca class=\"btn\" href=\"{APP_URL}/_/#/auth/confirm-password-reset/{TOKEN}\" target=\"_blank\" rel=\"noopener\"\u003eReset password\u003c/a\u003e\n\u003c/p\u003e\n\u003cp\u003e\u003ci\u003eIf you didn't ask to reset your password, you can ignore this email.\u003c/i\u003e\u003c/p\u003e\n\u003cp\u003e\n Thanks,\u003cbr/\u003e\n {APP_NAME} team\n\u003c/p\u003e"
|
||||||
|
},
|
||||||
|
"confirmEmailChangeTemplate": {
|
||||||
|
"subject": "Confirm your {APP_NAME} new email address",
|
||||||
|
"body": "\u003cp\u003eHello,\u003c/p\u003e\n\u003cp\u003eClick on the button below to confirm your new email address.\u003c/p\u003e\n\u003cp\u003e\n \u003ca class=\"btn\" href=\"{APP_URL}/_/#/auth/confirm-email-change/{TOKEN}\" target=\"_blank\" rel=\"noopener\"\u003eConfirm new email\u003c/a\u003e\n\u003c/p\u003e\n\u003cp\u003e\u003ci\u003eIf you didn't ask to change your email address, you can ignore this email.\u003c/i\u003e\u003c/p\u003e\n\u003cp\u003e\n Thanks,\u003cbr/\u003e\n {APP_NAME} team\n\u003c/p\u003e"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "pbc_4195113088",
|
||||||
|
"listRule": "@request.auth.id != \"\"",
|
||||||
|
"viewRule": "@request.auth.id != \"\"",
|
||||||
|
"createRule": "@request.auth.id != \"\"",
|
||||||
|
"updateRule": null,
|
||||||
|
"deleteRule": null,
|
||||||
|
"name": "cameras",
|
||||||
|
"type": "base",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "[a-z0-9]{15}",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text3208210256",
|
||||||
|
"max": 15,
|
||||||
|
"min": 15,
|
||||||
|
"name": "id",
|
||||||
|
"pattern": "^[a-z0-9]+$",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": true,
|
||||||
|
"required": true,
|
||||||
|
"system": true,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text1579384326",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "name",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text2841347104",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "onvif_host",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text4166911607",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "username",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text901924565",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "password",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate2990389176",
|
||||||
|
"name": "created",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": false,
|
||||||
|
"presentable": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "autodate"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate3332085495",
|
||||||
|
"name": "updated",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": true,
|
||||||
|
"presentable": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "autodate"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cascadeDelete": false,
|
||||||
|
"collectionId": "pbc_2662073616",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "relation2461399138",
|
||||||
|
"maxSelect": 0,
|
||||||
|
"minSelect": 0,
|
||||||
|
"name": "record_stream",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "relation"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"indexes": [],
|
||||||
|
"created": "2026-04-03 11:46:26.469Z",
|
||||||
|
"updated": "2026-04-03 11:46:26.475Z",
|
||||||
|
"system": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "pbc_2662073616",
|
||||||
|
"listRule": "@request.auth.id != \"\"",
|
||||||
|
"viewRule": "@request.auth.id != \"\"",
|
||||||
|
"createRule": null,
|
||||||
|
"updateRule": null,
|
||||||
|
"deleteRule": null,
|
||||||
|
"name": "streams",
|
||||||
|
"type": "base",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "[a-z0-9]{15}",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text3208210256",
|
||||||
|
"max": 15,
|
||||||
|
"min": 15,
|
||||||
|
"name": "id",
|
||||||
|
"pattern": "^[a-z0-9]+$",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": true,
|
||||||
|
"required": true,
|
||||||
|
"system": true,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cascadeDelete": true,
|
||||||
|
"collectionId": "pbc_4195113088",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "relation991751685",
|
||||||
|
"maxSelect": 0,
|
||||||
|
"minSelect": 0,
|
||||||
|
"name": "camera",
|
||||||
|
"presentable": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "relation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text4101391790",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "url",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "number1428699120",
|
||||||
|
"max": null,
|
||||||
|
"min": 0,
|
||||||
|
"name": "fps",
|
||||||
|
"onlyInt": false,
|
||||||
|
"presentable": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "number4115522831",
|
||||||
|
"max": null,
|
||||||
|
"min": 0,
|
||||||
|
"name": "height",
|
||||||
|
"onlyInt": true,
|
||||||
|
"presentable": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "number2350531887",
|
||||||
|
"max": null,
|
||||||
|
"min": 0,
|
||||||
|
"name": "width",
|
||||||
|
"onlyInt": true,
|
||||||
|
"presentable": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"indexes": [],
|
||||||
|
"created": "2026-04-03 11:46:26.472Z",
|
||||||
|
"updated": "2026-04-03 11:46:26.472Z",
|
||||||
|
"system": false
|
||||||
|
}
|
||||||
|
]
|
||||||
27
server/config/config.go
Normal file
27
server/config/config.go
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/caarlos0/env/v11"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Environment string `env:"ENVIRONMENT" envDefault:"production"`
|
||||||
|
DbDataDir string `env:"CCTV_PB_DATA_DIR" envDefault:"./pb_data"`
|
||||||
|
DbEncryptionKey string `env:"CCTV_PB_ENCRYPTION_KEY" envDefault:""`
|
||||||
|
RecordingsDir string `env:"CCTV_REC_DIR" envDefault:"./recordings"`
|
||||||
|
ExternalWebApp string `env:"CCTV_EXTERNAL_WEB_APP" envDefault:""`
|
||||||
|
}
|
||||||
|
|
||||||
|
func Load() (*Config, error) {
|
||||||
|
var cfg Config
|
||||||
|
if err := env.ParseWithOptions(&cfg, env.Options{
|
||||||
|
RequiredIfNoDef: true,
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) IsDev() bool {
|
||||||
|
return c.Environment == "development"
|
||||||
|
}
|
||||||
52
server/go.mod
Normal file
52
server/go.mod
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
module git.koval.net/cyclane/cctv/server
|
||||||
|
|
||||||
|
go 1.26.1
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/caarlos0/env/v11 v11.4.0
|
||||||
|
github.com/go-gst/go-glib v1.4.1-0.20250303082535-35ebad1471fd
|
||||||
|
github.com/go-gst/go-gst v1.4.0
|
||||||
|
github.com/gowvp/onvif v0.0.14
|
||||||
|
github.com/joho/godotenv v1.5.1
|
||||||
|
github.com/pocketbase/pocketbase v0.36.7
|
||||||
|
golang.org/x/sync v0.20.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
|
||||||
|
github.com/beevik/etree v1.1.0 // indirect
|
||||||
|
github.com/disintegration/imaging v1.6.2 // indirect
|
||||||
|
github.com/domodwyer/mailyak/v3 v3.6.2 // indirect
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/elgs/gostrgen v0.0.0-20251010065124-dce324c66371 // indirect
|
||||||
|
github.com/fatih/color v1.18.0 // indirect
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||||
|
github.com/ganigeorgiev/fexpr v0.5.0 // indirect
|
||||||
|
github.com/go-gst/go-pointer v0.0.0-20241127163939-ba766f075b4c // indirect
|
||||||
|
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect
|
||||||
|
github.com/gofrs/uuid v4.4.0+incompatible // indirect
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
|
github.com/juju/errors v1.0.0 // indirect
|
||||||
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/mattn/go-pointer v0.0.1 // indirect
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
|
github.com/pocketbase/dbx v1.12.0 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
github.com/spf13/cast v1.10.0 // indirect
|
||||||
|
github.com/spf13/cobra v1.10.2 // indirect
|
||||||
|
github.com/spf13/pflag v1.0.10 // indirect
|
||||||
|
golang.org/x/crypto v0.49.0 // indirect
|
||||||
|
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||||
|
golang.org/x/image v0.37.0 // indirect
|
||||||
|
golang.org/x/net v0.52.0 // indirect
|
||||||
|
golang.org/x/oauth2 v0.36.0 // indirect
|
||||||
|
golang.org/x/sys v0.42.0 // indirect
|
||||||
|
golang.org/x/text v0.35.0 // indirect
|
||||||
|
modernc.org/libc v1.70.0 // indirect
|
||||||
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
modernc.org/sqlite v1.46.2 // indirect
|
||||||
|
)
|
||||||
154
server/go.sum
Normal file
154
server/go.sum
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg=
|
||||||
|
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
|
||||||
|
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
|
||||||
|
github.com/beevik/etree v1.1.0 h1:T0xke/WvNtMoCqgzPhkX2r4rjY3GDZFi+FjpRZY2Jbs=
|
||||||
|
github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A=
|
||||||
|
github.com/caarlos0/env/v11 v11.4.0 h1:Kcb6t5kIIr4XkoQC9AF2j+8E1Jsrl3Wz/hhm1LtoGAc=
|
||||||
|
github.com/caarlos0/env/v11 v11.4.0/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
|
||||||
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
|
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
||||||
|
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||||
|
github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCOBXMn8=
|
||||||
|
github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/elgs/gostrgen v0.0.0-20251010065124-dce324c66371 h1:4WADfZZW26C7UgER4MEwZpS/THGj0VEf6HiXS3PyRfo=
|
||||||
|
github.com/elgs/gostrgen v0.0.0-20251010065124-dce324c66371/go.mod h1:qxVxKgX2MC/LcAmvASQ96hjjlcBOV6wQ+ZV9r1+nB3k=
|
||||||
|
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||||
|
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||||
|
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||||
|
github.com/ganigeorgiev/fexpr v0.5.0 h1:XA9JxtTE/Xm+g/JFI6RfZEHSiQlk+1glLvRK1Lpv/Tk=
|
||||||
|
github.com/ganigeorgiev/fexpr v0.5.0/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
|
||||||
|
github.com/go-gst/go-glib v1.4.1-0.20250303082535-35ebad1471fd h1:mKnu0UUFvvDMEeK4HXxmX+haP6+qppIvCW0HgUOfwmk=
|
||||||
|
github.com/go-gst/go-glib v1.4.1-0.20250303082535-35ebad1471fd/go.mod h1:ZWT4LXOO2PH8lSNu/dR5O2yoNQJKEgmijNa2d7nByK8=
|
||||||
|
github.com/go-gst/go-gst v1.4.0 h1:EikB43u4c3wc8d2RzlFRSfIGIXYzDy6Zls2vJqrG2BU=
|
||||||
|
github.com/go-gst/go-gst v1.4.0/go.mod h1:p8TLGtOxJLcrp6PCkTPdnanwWBxPZvYiHDbuSuwgO3c=
|
||||||
|
github.com/go-gst/go-pointer v0.0.0-20241127163939-ba766f075b4c h1:x8kKRVDmz5BRlolmDZGcsuZ1l+js6TRL3QWBJjGVctM=
|
||||||
|
github.com/go-gst/go-pointer v0.0.0-20241127163939-ba766f075b4c/go.mod h1:qKw5ZZ0U58W6PU/7F/Lopv+14nKYmdXlOd7VnAZ17Mk=
|
||||||
|
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 h1:byhDUpfEwjsVQb1vBunvIjh2BHQ9ead57VkAEY4V+Es=
|
||||||
|
github.com/go-ozzo/ozzo-validation/v4 v4.3.0/go.mod h1:2NKgrcHl3z6cJs+3Oo940FPRiTzuqKbvfrL2RxCj6Ew=
|
||||||
|
github.com/go-sql-driver/mysql v1.4.1 h1:g24URVg0OFbNUTx9qqY1IRZ9D9z3iPyi5zKhQZpNwpA=
|
||||||
|
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
|
||||||
|
github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA=
|
||||||
|
github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
|
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc=
|
||||||
|
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/gowvp/onvif v0.0.14 h1:NNrFqzqBHf9Z9MEQOiDpunpagXUHQraRjFmyiXhUwr4=
|
||||||
|
github.com/gowvp/onvif v0.0.14/go.mod h1:Dshr55Q/Xgwa9XMQBPBQBMOWj/2Sq+DxLhdNY35uoFc=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
|
github.com/juju/errors v1.0.0 h1:yiq7kjCLll1BiaRuNY53MGI0+EQ3rF6GB+wvboZDefM=
|
||||||
|
github.com/juju/errors v1.0.0/go.mod h1:B5x9thDqx0wIMH3+aLIMP9HjItInYWObRovoCFM5Qe8=
|
||||||
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||||
|
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-pointer v0.0.1 h1:n+XhsuGeVO6MEAp7xyEukFINEa+Quek5psIR/ylA6o0=
|
||||||
|
github.com/mattn/go-pointer v0.0.1/go.mod h1:2zXcozF6qYGgmsG+SeTZz3oAbFLdD3OWqnUbNvJZAlc=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/pocketbase/dbx v1.12.0 h1:/oLErM+A0b4xI0PWTGPqSDVjzix48PqI/bng2l0PzoA=
|
||||||
|
github.com/pocketbase/dbx v1.12.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
|
||||||
|
github.com/pocketbase/pocketbase v0.36.7 h1:MrViB7BptPYrf2Nt25pJEYBqUdFjuhRKu1p5GTrkvPA=
|
||||||
|
github.com/pocketbase/pocketbase v0.36.7/go.mod h1:qX4HuVjoKXtEg41fSJVM0JLfGWXbBmHxVv/FaE446r4=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||||
|
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||||
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
|
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||||
|
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||||
|
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||||
|
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||||
|
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||||
|
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
|
||||||
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||||
|
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||||
|
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
||||||
|
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
||||||
|
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||||
|
golang.org/x/image v0.37.0 h1:ZiRjArKI8GwxZOoEtUfhrBtaCN+4b/7709dlT6SSnQA=
|
||||||
|
golang.org/x/image v0.37.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
|
||||||
|
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||||
|
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||||
|
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||||
|
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||||
|
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||||
|
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||||
|
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||||
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||||
|
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||||
|
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||||
|
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||||
|
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||||
|
google.golang.org/appengine v1.6.5 h1:tycE03LOZYQNhDpS27tcQdAzLCVMaj7QT2SXxebnpCM=
|
||||||
|
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
|
||||||
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||||
|
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||||
|
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
|
||||||
|
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
|
||||||
|
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||||
|
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||||
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
|
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||||
|
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||||
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
|
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
|
||||||
|
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
|
||||||
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
|
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||||
|
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
|
modernc.org/sqlite v1.46.2 h1:gkXQ6R0+AjxFC/fTDaeIVLbNLNrRoOK7YYVz5BKhTcE=
|
||||||
|
modernc.org/sqlite v1.46.2/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
|
||||||
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
67
server/hooks.go
Normal file
67
server/hooks.go
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
|
"git.koval.net/cyclane/cctv/server/ingest"
|
||||||
|
"git.koval.net/cyclane/cctv/server/models"
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
"golang.org/x/sync/errgroup"
|
||||||
|
)
|
||||||
|
|
||||||
|
func handleCameraUpdate(e *core.RecordEvent) error {
|
||||||
|
if e.Record.Collection().Name != "cameras" {
|
||||||
|
return e.Next()
|
||||||
|
}
|
||||||
|
camera := &models.Camera{}
|
||||||
|
camera.SetProxyRecord(e.Record)
|
||||||
|
log := e.App.Logger().With("camera", camera.Id)
|
||||||
|
|
||||||
|
// Encrypt password
|
||||||
|
password := camera.PasswordRaw()
|
||||||
|
log.Info("Old password", "password", password)
|
||||||
|
if err := camera.SetPassword(password, e.App.EncryptionEnv()); err != nil {
|
||||||
|
return fmt.Errorf("failed to encrypt password: %w", err)
|
||||||
|
}
|
||||||
|
log.Info("New password", "password", camera.PasswordRaw())
|
||||||
|
|
||||||
|
onvifProfiles, err := ingest.GetOnvifProfiles(e.Context, camera.OnvifHost(), camera.Username(), password)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to find onvif profiles: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := e.Next(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
streams, err := e.App.FindCollectionByNameOrId("streams")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to find streams collection: %w", err)
|
||||||
|
}
|
||||||
|
errGroup := &errgroup.Group{}
|
||||||
|
for _, p := range onvifProfiles {
|
||||||
|
log.Debug("Found ONVIF profile", "name", p.Name, "token", p.Token)
|
||||||
|
errGroup.Go(func() error {
|
||||||
|
log := log.With("profile", p.Token)
|
||||||
|
rawURL := p.URI.String()
|
||||||
|
rtspUrl := p.URI
|
||||||
|
rtspUrl.User = url.UserPassword(camera.Username(), password)
|
||||||
|
stats, err := ingest.GetStreamStats(e.Context, log, rtspUrl, string(p.Token))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get stream stats for profile %s: %w", p.Token, err)
|
||||||
|
}
|
||||||
|
log.Debug("Retrieved stream stats", "fps", stats.FPS, "width", stats.Width, "height", stats.Height)
|
||||||
|
|
||||||
|
stream := &models.Stream{}
|
||||||
|
stream.SetProxyRecord(core.NewRecord(streams))
|
||||||
|
stream.SetCameraID(camera.Id)
|
||||||
|
stream.SetURL(rawURL)
|
||||||
|
stream.SetFPS(stats.FPS)
|
||||||
|
stream.SetWidth(stats.Width)
|
||||||
|
stream.SetHeight(stats.Height)
|
||||||
|
return e.App.Save(stream)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return errGroup.Wait()
|
||||||
|
}
|
||||||
335
server/ingest/branch_live.go
Normal file
335
server/ingest/branch_live.go
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
package ingest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-gst/go-gst/gst"
|
||||||
|
"github.com/go-gst/go-gst/gst/app"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
liveSampleTimeout = 200 * time.Millisecond
|
||||||
|
liveSampleBufferSize = 5
|
||||||
|
)
|
||||||
|
|
||||||
|
type subscribeReq struct {
|
||||||
|
BufferSize int
|
||||||
|
Result chan<- subscribeRes
|
||||||
|
}
|
||||||
|
type subscribeRes struct {
|
||||||
|
Id int
|
||||||
|
Stream <-chan *bytes.Reader
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
type unsubscribeReq struct {
|
||||||
|
Id int
|
||||||
|
}
|
||||||
|
|
||||||
|
// addLiveBranch populates the pipeline with:
|
||||||
|
//
|
||||||
|
// [vTee] ---> queue ---> valve ---> mp4mux ---> appsink
|
||||||
|
// [aTee] ---> queue ---> valve -----^
|
||||||
|
func (p *CameraPipeline) addLiveBranch(branchTimeout time.Duration) error {
|
||||||
|
if p.vTee == nil {
|
||||||
|
return fmt.Errorf("video tee not initialized")
|
||||||
|
}
|
||||||
|
if p.aTee == nil {
|
||||||
|
return fmt.Errorf("audio tee not initialized")
|
||||||
|
}
|
||||||
|
vPad := p.vTee.GetRequestPad("src_%u")
|
||||||
|
if vPad == nil {
|
||||||
|
return fmt.Errorf("failed to get request pad from video tee")
|
||||||
|
}
|
||||||
|
aPad := p.aTee.GetRequestPad("src_%u")
|
||||||
|
if aPad == nil {
|
||||||
|
return fmt.Errorf("failed to get request pad from audio tee")
|
||||||
|
}
|
||||||
|
|
||||||
|
vQueue, err := gst.NewElement("queue")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create video queue element: %w", err)
|
||||||
|
}
|
||||||
|
if err := setGlibValueProperty(vQueue, "leaky", 2); err != nil { // downstream
|
||||||
|
return fmt.Errorf("failed to set video queue leaky property: %w", err)
|
||||||
|
}
|
||||||
|
if err := vQueue.SetProperty("max-size-time", uint64(2*time.Second.Nanoseconds())); err != nil {
|
||||||
|
return fmt.Errorf("failed to set video queue max-size-time property: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
vValve, err := gst.NewElement("valve")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create video valve element: %w", err)
|
||||||
|
}
|
||||||
|
if err := vValve.SetProperty("drop", true); err != nil {
|
||||||
|
return fmt.Errorf("failed to set video valve drop property: %w", err)
|
||||||
|
}
|
||||||
|
if err := setGlibValueProperty(vValve, "drop-mode", 1); err != nil { // forward-sticky
|
||||||
|
return fmt.Errorf("failed to set video valve drop-mode property: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
aQueue, err := gst.NewElement("queue")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create audio queue element: %w", err)
|
||||||
|
}
|
||||||
|
if err := setGlibValueProperty(aQueue, "leaky", 2); err != nil { // downstream
|
||||||
|
return fmt.Errorf("failed to set audio queue leaky property: %w", err)
|
||||||
|
}
|
||||||
|
if err := aQueue.SetProperty("max-size-time", uint64(2*time.Second.Nanoseconds())); err != nil {
|
||||||
|
return fmt.Errorf("failed to set audio queue max-size-time property: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
aValve, err := gst.NewElement("valve")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create audio valve element: %w", err)
|
||||||
|
}
|
||||||
|
if err := aValve.SetProperty("drop", true); err != nil {
|
||||||
|
return fmt.Errorf("failed to set audio valve drop property: %w", err)
|
||||||
|
}
|
||||||
|
if err := setGlibValueProperty(aValve, "drop-mode", 1); err != nil { // forward-sticky
|
||||||
|
return fmt.Errorf("failed to set audio valve drop-mode property: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mp4Mux, err := gst.NewElement("mp4mux")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create mp4mux element: %w", err)
|
||||||
|
}
|
||||||
|
if err := mp4Mux.SetProperty("fragment-duration", uint(100*time.Millisecond.Nanoseconds())); err != nil {
|
||||||
|
return fmt.Errorf("failed to set mp4mux fragment-duration property: %w", err)
|
||||||
|
}
|
||||||
|
if err := setGlibValueProperty(mp4Mux, "fragment-mode", 0); err != nil { // dash-or-mss
|
||||||
|
return fmt.Errorf("failed to set mp4mux fragment-mode property: %w", err)
|
||||||
|
}
|
||||||
|
if err := mp4Mux.SetProperty("interleave-time", uint64(0)); err != nil {
|
||||||
|
return fmt.Errorf("failed to set mp4mux interleave-time property: %w", err)
|
||||||
|
}
|
||||||
|
if err := mp4Mux.SetProperty("latency", uint64(0)); err != nil {
|
||||||
|
return fmt.Errorf("failed to set mp4mux latency property: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
appSink, err := gst.NewElement("appsink")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create appsink element: %w", err)
|
||||||
|
}
|
||||||
|
if err := appSink.SetProperty("max-buffers", uint(5)); err != nil {
|
||||||
|
return fmt.Errorf("failed to set appsink max-buffers property: %w", err)
|
||||||
|
}
|
||||||
|
if err := appSink.SetProperty("sync", false); err != nil {
|
||||||
|
return fmt.Errorf("failed to set appsink sync property: %w", err)
|
||||||
|
}
|
||||||
|
if err := appSink.SetProperty("async", false); err != nil {
|
||||||
|
return fmt.Errorf("failed to set appsink async property: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := p.pipeline.AddMany(vQueue, vValve, aQueue, aValve, mp4Mux, appSink); err != nil {
|
||||||
|
return fmt.Errorf("failed to add live branch elements to pipeline: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if res := vPad.Link(vQueue.GetStaticPad("sink")); res != gst.PadLinkOK {
|
||||||
|
return fmt.Errorf("failed to link video tee to video queue: %s", res)
|
||||||
|
}
|
||||||
|
if res := aPad.Link(aQueue.GetStaticPad("sink")); res != gst.PadLinkOK {
|
||||||
|
return fmt.Errorf("failed to link audio tee to audio queue: %s", res)
|
||||||
|
}
|
||||||
|
if res := vQueue.GetStaticPad("src").Link(vValve.GetStaticPad("sink")); res != gst.PadLinkOK {
|
||||||
|
return fmt.Errorf("failed to link video queue to video valve: %s", res)
|
||||||
|
}
|
||||||
|
if res := aQueue.GetStaticPad("src").Link(aValve.GetStaticPad("sink")); res != gst.PadLinkOK {
|
||||||
|
return fmt.Errorf("failed to link audio queue to audio valve: %s", res)
|
||||||
|
}
|
||||||
|
if res := vValve.GetStaticPad("src").Link(mp4Mux.GetRequestPad("video_%u")); res != gst.PadLinkOK {
|
||||||
|
return fmt.Errorf("failed to link video valve to mp4mux: %s", res)
|
||||||
|
}
|
||||||
|
if res := aValve.GetStaticPad("src").Link(mp4Mux.GetRequestPad("audio_%u")); res != gst.PadLinkOK {
|
||||||
|
return fmt.Errorf("failed to link audio valve to mp4mux: %s", res)
|
||||||
|
}
|
||||||
|
if res := mp4Mux.GetStaticPad("src").Link(appSink.GetStaticPad("sink")); res != gst.PadLinkOK {
|
||||||
|
return fmt.Errorf("failed to link mp4mux to appsink: %s", res)
|
||||||
|
}
|
||||||
|
|
||||||
|
p.liveVValve = vValve
|
||||||
|
p.liveAValve = aValve
|
||||||
|
p.liveSink = app.SinkFromElement(appSink)
|
||||||
|
p.liveTimeout = branchTimeout
|
||||||
|
p.liveSubscribe = make(chan subscribeReq)
|
||||||
|
p.liveUnsubscribe = make(chan unsubscribeReq)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *CameraPipeline) liveSampler(ctx context.Context, result chan<- []byte) {
|
||||||
|
SamplerLoop:
|
||||||
|
for {
|
||||||
|
sample := p.liveSink.TryPullSample(gst.ClockTime(liveSampleTimeout.Nanoseconds()))
|
||||||
|
if sample == nil {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
p.log.Debug("Live sampler timed out waiting for sample")
|
||||||
|
continue SamplerLoop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buf := sample.GetBuffer()
|
||||||
|
if buf == nil {
|
||||||
|
p.log.Warn("Received nil buffer from live sink")
|
||||||
|
continue SamplerLoop
|
||||||
|
}
|
||||||
|
data, err := gstBufferToBytes(buf)
|
||||||
|
if err != nil {
|
||||||
|
p.log.Warn("Failed to convert buffer to bytes")
|
||||||
|
continue SamplerLoop
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case result <- data:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *CameraPipeline) liveManager(ctx context.Context) {
|
||||||
|
active := false
|
||||||
|
var initSeg []byte
|
||||||
|
subscribers := &subscribersManager{m: make(map[int]subscriber), nextID: 0}
|
||||||
|
samples := make(chan []byte, liveSampleBufferSize)
|
||||||
|
samplerCtx, samplerCancel := context.WithCancel(ctx)
|
||||||
|
defer func() {
|
||||||
|
subscribers.Close()
|
||||||
|
samplerCancel()
|
||||||
|
}()
|
||||||
|
|
||||||
|
defer p.log.Debug("Live branch manager exiting")
|
||||||
|
var start time.Time
|
||||||
|
var numSamples int
|
||||||
|
for {
|
||||||
|
var timeout <-chan time.Time
|
||||||
|
if active && subscribers.len() == 0 {
|
||||||
|
timeout = time.After(p.liveTimeout)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
samplerCancel()
|
||||||
|
p.log.Info("Context cancelled", "numSamples", numSamples, "duration", time.Since(start))
|
||||||
|
return
|
||||||
|
case req := <-p.liveSubscribe:
|
||||||
|
if !active {
|
||||||
|
p.log.Info("Activating live branch")
|
||||||
|
if err := p.liveVValve.SetProperty("drop", false); err != nil {
|
||||||
|
req.Result <- subscribeRes{Err: fmt.Errorf("failed to activate video valve: %w", err)}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := p.liveAValve.SetProperty("drop", false); err != nil {
|
||||||
|
if rollbackErr := p.liveVValve.SetProperty("drop", true); rollbackErr != nil { // try to rollback
|
||||||
|
p.log.Warn("Failed to rollback video valve state after audio valve activation failure", "error", rollbackErr)
|
||||||
|
}
|
||||||
|
req.Result <- subscribeRes{Err: fmt.Errorf("failed to activate audio valve: %w", err)}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
go p.liveSampler(samplerCtx, samples)
|
||||||
|
active = true
|
||||||
|
start = time.Now()
|
||||||
|
}
|
||||||
|
stream := make(chan *bytes.Reader, req.BufferSize)
|
||||||
|
id := subscribers.AddSubscriber(stream)
|
||||||
|
if initSeg != nil {
|
||||||
|
if !subscribers.SendOrUnsubscribe(id, initSeg) {
|
||||||
|
req.Result <- subscribeRes{Err: fmt.Errorf("subscriber missed init segment")}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
req.Result <- subscribeRes{Id: id, Stream: stream}
|
||||||
|
case req := <-p.liveUnsubscribe:
|
||||||
|
subscribers.RemoveSubscriber(req.Id)
|
||||||
|
case data := <-samples:
|
||||||
|
// TODO : init segment
|
||||||
|
p.log.Debug("got video sample")
|
||||||
|
numSamples += 1
|
||||||
|
subscribers.Broadcast(data)
|
||||||
|
case <-timeout:
|
||||||
|
p.log.Info("Deactivating live branch due to inactivity")
|
||||||
|
if err := p.liveVValve.SetProperty("drop", true); err != nil {
|
||||||
|
p.log.Warn("Failed to deactivate video valve: %w", err)
|
||||||
|
}
|
||||||
|
if err := p.liveAValve.SetProperty("drop", true); err != nil {
|
||||||
|
p.log.Warn("Failed to deactivate audio valve: %w", err)
|
||||||
|
}
|
||||||
|
samplerCancel()
|
||||||
|
samplerCtx, samplerCancel = context.WithCancel(ctx)
|
||||||
|
active = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *CameraPipeline) LiveSubscribe(timeout time.Duration, bufferSize int) (id int, stream <-chan *bytes.Reader, err error) {
|
||||||
|
result := make(chan subscribeRes)
|
||||||
|
req := subscribeReq{
|
||||||
|
BufferSize: bufferSize,
|
||||||
|
Result: result,
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-time.After(timeout):
|
||||||
|
return 0, nil, fmt.Errorf("live subscribe request timed out")
|
||||||
|
case p.liveSubscribe <- req:
|
||||||
|
}
|
||||||
|
res := <-result
|
||||||
|
return res.Id, res.Stream, res.Err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *CameraPipeline) LiveUnsubscribe(id int) {
|
||||||
|
p.liveUnsubscribe <- unsubscribeReq{Id: id}
|
||||||
|
}
|
||||||
|
|
||||||
|
type subscriber struct {
|
||||||
|
Stream chan<- *bytes.Reader
|
||||||
|
}
|
||||||
|
|
||||||
|
type subscribersManager struct {
|
||||||
|
m map[int]subscriber
|
||||||
|
nextID int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sm subscribersManager) len() int {
|
||||||
|
return len(sm.m)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sm *subscribersManager) AddSubscriber(stream chan<- *bytes.Reader) int {
|
||||||
|
id := sm.nextID
|
||||||
|
sm.m[id] = subscriber{Stream: stream}
|
||||||
|
sm.nextID++
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sm *subscribersManager) RemoveSubscriber(id int) {
|
||||||
|
if sub, ok := sm.m[id]; ok {
|
||||||
|
close(sub.Stream)
|
||||||
|
delete(sm.m, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sm *subscribersManager) SendOrUnsubscribe(id int, data []byte) bool {
|
||||||
|
if sub, ok := sm.m[id]; ok {
|
||||||
|
select {
|
||||||
|
case sub.Stream <- bytes.NewReader(data):
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
sm.RemoveSubscriber(id)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sm *subscribersManager) Close() {
|
||||||
|
for id := range sm.m {
|
||||||
|
sm.RemoveSubscriber(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sm *subscribersManager) Broadcast(data []byte) {
|
||||||
|
for id := range sm.m {
|
||||||
|
sm.SendOrUnsubscribe(id, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
111
server/ingest/branch_stats.go
Normal file
111
server/ingest/branch_stats.go
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
package ingest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/go-gst/go-gst/gst"
|
||||||
|
)
|
||||||
|
|
||||||
|
// addStatsBranch populates the pipeline with:
|
||||||
|
//
|
||||||
|
// [vTee] ---> fakesink
|
||||||
|
//
|
||||||
|
// StreamStats are extracted from the fakesink's handovers (using the sink pad caps) and sent to the returned channel.
|
||||||
|
// If the channel has no received or is full, the stats are dropped to avoid blocking the pipeline. The channel is never closed.
|
||||||
|
func (p *CameraPipeline) addStatsBranch() (<-chan StreamStats, error) {
|
||||||
|
if p.vTee == nil {
|
||||||
|
return nil, fmt.Errorf("video tee not initialized")
|
||||||
|
}
|
||||||
|
vPad := p.vTee.GetRequestPad("src_%u")
|
||||||
|
if vPad == nil {
|
||||||
|
return nil, fmt.Errorf("failed to get request pad from video tee")
|
||||||
|
}
|
||||||
|
|
||||||
|
statsChan := make(chan StreamStats)
|
||||||
|
fakeSink, err := gst.NewElement("fakesink")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create fakesink element: %w", err)
|
||||||
|
}
|
||||||
|
if err := fakeSink.SetProperty("signal-handoffs", true); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to set fakesink signal-handoffs property: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := p.pipeline.Add(fakeSink); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to add fakesink to pipeline: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if res := vPad.Link(fakeSink.GetStaticPad("sink")); res != gst.PadLinkOK {
|
||||||
|
return nil, fmt.Errorf("failed to link video tee to fakesink: %s", res)
|
||||||
|
}
|
||||||
|
|
||||||
|
fakeSink.Connect("handoff", func(element *gst.Element, _ *gst.Buffer, pad *gst.Pad) {
|
||||||
|
caps := pad.GetCurrentCaps()
|
||||||
|
if caps == nil || caps.GetSize() == 0 {
|
||||||
|
p.log.Error("Stats pad has no or empty caps")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if caps.GetSize() > 1 {
|
||||||
|
// Note: should be impossible
|
||||||
|
p.log.Warn("Stats pad has multiple structures, using the first one", "caps", caps.String())
|
||||||
|
}
|
||||||
|
structure := caps.GetStructureAt(0)
|
||||||
|
if structure == nil {
|
||||||
|
p.log.Error("Stats pad received nil structure")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rawWidth, err := structure.GetValue("width")
|
||||||
|
if err != nil {
|
||||||
|
p.log.Error("Failed to get width from stats pad caps", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
width, ok := rawWidth.(int)
|
||||||
|
if !ok {
|
||||||
|
p.log.Error("Width field in stats pad caps is not an int", "value", rawWidth)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rawHeight, err := structure.GetValue("height")
|
||||||
|
if err != nil {
|
||||||
|
p.log.Error("Failed to get height from stats pad caps", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
height, ok := rawHeight.(int)
|
||||||
|
if !ok {
|
||||||
|
p.log.Error("Height field in stats pad caps is not an int", "value", rawHeight)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fps, err := structure.GetValue("framerate")
|
||||||
|
if err != nil {
|
||||||
|
p.log.Error("Failed to get framerate from stats pad caps", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var fpsFloat float64
|
||||||
|
switch v := fps.(type) {
|
||||||
|
case *gst.FractionValue:
|
||||||
|
fpsFloat = float64(v.Num()) / float64(v.Denom())
|
||||||
|
case gst.FractionValue:
|
||||||
|
fpsFloat = float64(v.Num()) / float64(v.Denom())
|
||||||
|
case float64:
|
||||||
|
fpsFloat = v
|
||||||
|
default:
|
||||||
|
p.log.Error("Framerate field in stats pad caps is not a fraction or float", "type", fmt.Sprintf("%T", fps), "value", fps)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
|
||||||
|
case statsChan <- StreamStats{
|
||||||
|
FPS: fpsFloat,
|
||||||
|
Width: width,
|
||||||
|
Height: height,
|
||||||
|
}:
|
||||||
|
default:
|
||||||
|
p.log.Warn("Stats channel is full, dropping stats update")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
})
|
||||||
|
return statsChan, nil
|
||||||
|
}
|
||||||
306
server/ingest/branch_thumbnail.go
Normal file
306
server/ingest/branch_thumbnail.go
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
package ingest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-gst/go-gst/gst"
|
||||||
|
"github.com/go-gst/go-gst/gst/app"
|
||||||
|
)
|
||||||
|
|
||||||
|
type thumbnailReq struct {
|
||||||
|
Result chan<- thumbnailRes
|
||||||
|
}
|
||||||
|
type thumbnailRes struct {
|
||||||
|
Reader *bytes.Reader
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// addThumbnailBranch populates the pipeline with:
|
||||||
|
//
|
||||||
|
// [vTee] ---> identity[drop D, limit fps] ---> queue ---> [dynamic decoder]
|
||||||
|
// ---> videoconvert ---> aspectratiocrop ---> videoscale ---> capsfilter
|
||||||
|
// ---> jpegenc ---> appsink
|
||||||
|
//
|
||||||
|
// Essentially, this branch:
|
||||||
|
// - Removes any deltas from the stream.
|
||||||
|
// - Limits FPS (we only have keyframes, so this is roughly possible before decoding).
|
||||||
|
// - Decode, convert, crop to aspect ratio, scale and encode to JPEG
|
||||||
|
// - Then we can query the appsink for thumbnails
|
||||||
|
//
|
||||||
|
// width and height are for the dimensions of the generated thumbnails.
|
||||||
|
// minInterval has no effect when zero or negative, otherwise specifies the minimum interval between thumbnails.
|
||||||
|
// timeoutSecs is used to deactivate this branch after a period of inactivity (to save CPU), this is done by blocking after the queue.
|
||||||
|
func (p *CameraPipeline) addThumbnailBranch(width, height int, minInterval, branchTimeout time.Duration) error {
|
||||||
|
if p.thumbnailSink != nil {
|
||||||
|
return fmt.Errorf("thumbnail branch already added")
|
||||||
|
}
|
||||||
|
if p.vTee == nil {
|
||||||
|
return fmt.Errorf("video tee not initialized")
|
||||||
|
}
|
||||||
|
vPad := p.vTee.GetRequestPad("src_%u")
|
||||||
|
if vPad == nil {
|
||||||
|
return fmt.Errorf("failed to get request pad from video tee")
|
||||||
|
}
|
||||||
|
|
||||||
|
preDecodeChain, err := p.addStaticChainProps(
|
||||||
|
vPad, nil,
|
||||||
|
elementFactory{name: "identity", props: map[string]any{
|
||||||
|
"drop-buffer-flags": gst.BufferFlagDeltaUnit,
|
||||||
|
}},
|
||||||
|
elementFactory{name: "queue", props: map[string]any{
|
||||||
|
"max-size-buffers": uint(1),
|
||||||
|
"leaky": 2, // downstream
|
||||||
|
}},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to add pre-decode chain: %w", err)
|
||||||
|
}
|
||||||
|
preDecodeSrc := preDecodeChain[len(preDecodeChain)-1].GetStaticPad("src")
|
||||||
|
if preDecodeSrc == nil {
|
||||||
|
return fmt.Errorf("failed to get src pad of pre-decode chain")
|
||||||
|
}
|
||||||
|
identitySrc := preDecodeChain[0].GetStaticPad("src")
|
||||||
|
if identitySrc == nil {
|
||||||
|
return fmt.Errorf("failed to get src pad of identity element in pre-decode chain")
|
||||||
|
}
|
||||||
|
|
||||||
|
transformChain, err := p.addStaticChainProps(
|
||||||
|
nil, nil,
|
||||||
|
elementFactory{name: "videoconvert", props: nil},
|
||||||
|
elementFactory{name: "aspectratiocrop", props: map[string]any{
|
||||||
|
"aspect-ratio": gst.Fraction(width, height),
|
||||||
|
}},
|
||||||
|
elementFactory{name: "videoscale", props: nil},
|
||||||
|
elementFactory{name: "capsfilter", props: map[string]any{
|
||||||
|
"caps": gst.NewCapsFromString(fmt.Sprintf("video/x-raw,width=%d,height=%d", width, height)),
|
||||||
|
}},
|
||||||
|
elementFactory{name: "jpegenc", props: map[string]any{
|
||||||
|
"quality": 85,
|
||||||
|
}},
|
||||||
|
elementFactory{name: "appsink", props: map[string]any{
|
||||||
|
"max-buffers": uint(1),
|
||||||
|
"drop": true,
|
||||||
|
"sync": false,
|
||||||
|
"async": false,
|
||||||
|
}},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to add transform chain: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if minInterval > 0 {
|
||||||
|
minInterval := uint64(minInterval.Nanoseconds())
|
||||||
|
lastKeptTS, haveLastTS := uint64(0), false
|
||||||
|
identitySrc.AddProbe(gst.PadProbeTypeBuffer, func(_ *gst.Pad, info *gst.PadProbeInfo) gst.PadProbeReturn {
|
||||||
|
buf := info.GetBuffer()
|
||||||
|
if buf == nil {
|
||||||
|
return gst.PadProbeOK
|
||||||
|
}
|
||||||
|
|
||||||
|
ts := buf.PresentationTimestamp()
|
||||||
|
if ts == gst.ClockTimeNone {
|
||||||
|
ts = buf.DecodingTimestamp()
|
||||||
|
}
|
||||||
|
if ts == gst.ClockTimeNone || ts < 0 {
|
||||||
|
return gst.PadProbeOK
|
||||||
|
}
|
||||||
|
|
||||||
|
curr := uint64(ts)
|
||||||
|
if haveLastTS {
|
||||||
|
resetOccurred := curr < lastKeptTS
|
||||||
|
if !resetOccurred && curr-lastKeptTS < minInterval {
|
||||||
|
return gst.PadProbeDrop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
haveLastTS = true
|
||||||
|
lastKeptTS = curr
|
||||||
|
return gst.PadProbeOK
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dynamically add decoder (decodebin has an internal queue that is unnecessary for our use case,
|
||||||
|
// so it just adds unwanted latency).
|
||||||
|
transformSink := transformChain[0].GetStaticPad("sink")
|
||||||
|
if transformSink == nil {
|
||||||
|
return fmt.Errorf("failed to get sink pad for transform chain")
|
||||||
|
}
|
||||||
|
once := sync.Once{}
|
||||||
|
preDecodeSrc.AddProbe(gst.PadProbeTypeEventDownstream, func(_ *gst.Pad, info *gst.PadProbeInfo) gst.PadProbeReturn {
|
||||||
|
event := info.GetEvent()
|
||||||
|
if event != nil && event.Type() == gst.EventTypeCaps {
|
||||||
|
caps := event.ParseCaps()
|
||||||
|
if caps == nil || caps.GetSize() == 0 {
|
||||||
|
p.log.Error("Thumbnail pad probe received caps event with no or empty caps")
|
||||||
|
return gst.PadProbeOK
|
||||||
|
}
|
||||||
|
if caps.GetSize() > 1 {
|
||||||
|
// Note: should be impossible
|
||||||
|
p.log.Warn("Thumbnail pad probe received caps event with multiple structures, using the first one", "caps", caps.String())
|
||||||
|
}
|
||||||
|
structure := caps.GetStructureAt(0)
|
||||||
|
if structure == nil {
|
||||||
|
p.log.Error("Thumbnail pad probe received caps event with nil structure")
|
||||||
|
return gst.PadProbeOK
|
||||||
|
}
|
||||||
|
|
||||||
|
res := gst.PadProbeOK
|
||||||
|
once.Do(func() {
|
||||||
|
res = gst.PadProbeRemove
|
||||||
|
var (
|
||||||
|
chain []*gst.Element
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
switch structure.Name() {
|
||||||
|
case "video/x-h264":
|
||||||
|
chain, err = p.addStaticChain(preDecodeSrc, transformSink, "avdec_h264")
|
||||||
|
case "video/x-h265":
|
||||||
|
chain, err = p.addStaticChain(preDecodeSrc, transformSink, "avdec_h265")
|
||||||
|
default:
|
||||||
|
p.log.Error("Thumbnail pad probe received caps event with unsupported encoding", "caps", caps.String())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
p.log.Error("Failed to add decoder chain in thumbnail pad probe", "caps", caps.String(), "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, element := range chain {
|
||||||
|
element.SyncStateWithParent()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return res
|
||||||
|
} else {
|
||||||
|
return gst.PadProbeOK
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
p.thumbnailBlockPad = preDecodeSrc
|
||||||
|
p.thumbnailSink = app.SinkFromElement(transformChain[len(transformChain)-1])
|
||||||
|
p.thumbnailTimeout = branchTimeout
|
||||||
|
p.thumbnailReq = make(chan thumbnailReq)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *CameraPipeline) addThumbnailBlockProbe() {
|
||||||
|
p.thumbnailBlockProbe = p.thumbnailBlockPad.AddProbe(gst.PadProbeTypeBlock, func(pad *gst.Pad, info *gst.PadProbeInfo) gst.PadProbeReturn {
|
||||||
|
p.thumbnailBlocked.Store(true)
|
||||||
|
return gst.PadProbeOK
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *CameraPipeline) handleThumbnailReq(active bool, lastThumbnail []byte) (bool, []byte, error) {
|
||||||
|
var sample *gst.Sample
|
||||||
|
if active {
|
||||||
|
sample = p.thumbnailSink.TryPullSample(0)
|
||||||
|
} else {
|
||||||
|
p.log.Info("Activating thumbnail branch")
|
||||||
|
// Install one-shot probe to drop the first buffer (which is a stale frame that was blocked by the blocking probe
|
||||||
|
// when the branch was first deactivated).
|
||||||
|
if p.thumbnailBlocked.Swap(false) {
|
||||||
|
dropProbeId := atomic.Pointer[uint64]{}
|
||||||
|
once := sync.Once{}
|
||||||
|
probeId := p.thumbnailBlockPad.AddProbe(gst.PadProbeTypeBuffer, func(_ *gst.Pad, _ *gst.PadProbeInfo) gst.PadProbeReturn {
|
||||||
|
if probeId := dropProbeId.Load(); probeId != nil {
|
||||||
|
p.thumbnailBlockPad.RemoveProbe(*probeId)
|
||||||
|
}
|
||||||
|
res := gst.PadProbeRemove
|
||||||
|
once.Do(func() {
|
||||||
|
res = gst.PadProbeDrop
|
||||||
|
})
|
||||||
|
return res
|
||||||
|
})
|
||||||
|
dropProbeId.Store(&probeId)
|
||||||
|
}
|
||||||
|
p.thumbnailBlockPad.RemoveProbe(p.thumbnailBlockProbe)
|
||||||
|
active = true
|
||||||
|
sample = p.thumbnailSink.PullSample()
|
||||||
|
}
|
||||||
|
if sample == nil {
|
||||||
|
if lastThumbnail == nil {
|
||||||
|
return active, lastThumbnail, fmt.Errorf("no thumbnail available")
|
||||||
|
} else {
|
||||||
|
p.log.Debug("Resolving last thumbnail")
|
||||||
|
return active, lastThumbnail, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.log.Debug("Resolving new thumbnail")
|
||||||
|
|
||||||
|
data, err := gstBufferToBytes(sample.GetBuffer())
|
||||||
|
if err != nil {
|
||||||
|
return active, lastThumbnail, fmt.Errorf("failed to convert thumbnail buffer to bytes: %w", err)
|
||||||
|
}
|
||||||
|
return active, data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *CameraPipeline) thumbnailManager(ctx context.Context) {
|
||||||
|
active := true
|
||||||
|
var lastThumbnail []byte
|
||||||
|
|
||||||
|
defer p.log.Debug("Thumbnail manager exiting")
|
||||||
|
ManagerLoop:
|
||||||
|
for {
|
||||||
|
var timeout <-chan time.Time
|
||||||
|
if active {
|
||||||
|
timeout = time.After(p.thumbnailTimeout)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case req := <-p.thumbnailReq:
|
||||||
|
var (
|
||||||
|
thumbnail []byte
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
active, thumbnail, err = p.handleThumbnailReq(active, lastThumbnail)
|
||||||
|
if err != nil {
|
||||||
|
req.Result <- thumbnailRes{Err: err}
|
||||||
|
continue ManagerLoop
|
||||||
|
}
|
||||||
|
|
||||||
|
lastThumbnail = thumbnail
|
||||||
|
select {
|
||||||
|
case req.Result <- thumbnailRes{Reader: bytes.NewReader(thumbnail)}:
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case <-timeout:
|
||||||
|
p.log.Info("Deactivating thumbnail branch due to inactivity")
|
||||||
|
// Install block probe to stop flow into the thumbnail branch
|
||||||
|
p.addThumbnailBlockProbe()
|
||||||
|
// Flush buffer to avoid sending stale thumbnail when reactivating
|
||||||
|
for {
|
||||||
|
sample := p.thumbnailSink.TryPullSample(0)
|
||||||
|
if sample == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
active = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetThumbnail either retrieves a JPEG thumbnail from the pipeline, or returns the previous if no new thumbnail is available.
|
||||||
|
func (p *CameraPipeline) GetThumbnail(ctx context.Context) (*bytes.Reader, error) {
|
||||||
|
if p.thumbnailSink == nil {
|
||||||
|
return nil, fmt.Errorf("thumbnail branch not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := make(chan thumbnailRes, 1)
|
||||||
|
select {
|
||||||
|
case p.thumbnailReq <- thumbnailReq{Result: resp}:
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, fmt.Errorf("context cancelled while requesting thumbnail")
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case thumbnail := <-resp:
|
||||||
|
return thumbnail.Reader, thumbnail.Err
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, fmt.Errorf("context cancelled while waiting for thumbnail")
|
||||||
|
}
|
||||||
|
}
|
||||||
110
server/ingest/discovery.go
Normal file
110
server/ingest/discovery.go
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
package ingest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
|
"github.com/gowvp/onvif"
|
||||||
|
"github.com/gowvp/onvif/media"
|
||||||
|
mediaSdk "github.com/gowvp/onvif/sdk/media"
|
||||||
|
onvifXsd "github.com/gowvp/onvif/xsd/onvif"
|
||||||
|
)
|
||||||
|
|
||||||
|
type OnvifProfile struct {
|
||||||
|
Name onvifXsd.Name
|
||||||
|
Token onvifXsd.ReferenceToken
|
||||||
|
URI *url.URL
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOnvifProfiles retrieves all available ONVIF stream profiles for a camera.
|
||||||
|
//
|
||||||
|
// Note that the result is very limited since many cameras provide extremely incomplete data,
|
||||||
|
// so it's safest to assume that we only have the bare minimum.
|
||||||
|
func GetOnvifProfiles(ctx context.Context, host, username, password string) ([]OnvifProfile, error) {
|
||||||
|
dev, err := onvif.NewDevice(onvif.DeviceParams{
|
||||||
|
Xaddr: host,
|
||||||
|
Username: username,
|
||||||
|
Password: password,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create ONVIF device: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get available profiles
|
||||||
|
profiles, err := mediaSdk.Call_GetProfiles(ctx, dev, media.GetProfiles{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get ONVIF profiles: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get available URIs for each profile
|
||||||
|
onvifProfiles := make([]OnvifProfile, 0, len(profiles.Profiles))
|
||||||
|
for _, p := range profiles.Profiles {
|
||||||
|
uri, err := mediaSdk.Call_GetStreamUri(ctx, dev, media.GetStreamUri{
|
||||||
|
StreamSetup: onvifXsd.StreamSetup{
|
||||||
|
Stream: onvifXsd.StreamType("RTP-Unicast"),
|
||||||
|
Transport: onvifXsd.Transport{
|
||||||
|
Protocol: onvifXsd.TransportProtocol("TCP"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ProfileToken: p.Token,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get stream URI for profile %s: %w", p.Token, err)
|
||||||
|
}
|
||||||
|
if uri.MediaUri.Uri != "" {
|
||||||
|
u, err := url.Parse(string(uri.MediaUri.Uri))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse stream URI for profile %s: %w", p.Token, err)
|
||||||
|
}
|
||||||
|
onvifProfiles = append(onvifProfiles, OnvifProfile{
|
||||||
|
Name: p.Name,
|
||||||
|
Token: p.Token,
|
||||||
|
URI: u,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return onvifProfiles, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type StreamStats struct {
|
||||||
|
FPS float64
|
||||||
|
Width int
|
||||||
|
Height int
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStreamStats starts a temporary pipeline to retrieve some basic (pre-decoding) information about the RTSP stream.
|
||||||
|
func GetStreamStats(ctx context.Context, log *slog.Logger, rtspUrl *url.URL, profileToken string) (stats StreamStats, err error) {
|
||||||
|
p, err := CreatePipeline(log)
|
||||||
|
if err != nil {
|
||||||
|
return stats, fmt.Errorf("failed to create pipeline: %w", err)
|
||||||
|
}
|
||||||
|
if err := p.addRtspSource(rtspUrl); err != nil {
|
||||||
|
return stats, fmt.Errorf("failed to add RTSP source: %w", err)
|
||||||
|
}
|
||||||
|
statsChan, err := p.addStatsBranch()
|
||||||
|
if err != nil {
|
||||||
|
return stats, fmt.Errorf("failed to add stats branch: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
statsFound := false
|
||||||
|
runCtx, cancel := context.WithCancel(ctx)
|
||||||
|
defer cancel()
|
||||||
|
go func() {
|
||||||
|
select {
|
||||||
|
case stats = <-statsChan:
|
||||||
|
statsFound = true
|
||||||
|
cancel()
|
||||||
|
case <-runCtx.Done():
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if err := p.Run(runCtx); err != nil {
|
||||||
|
return stats, fmt.Errorf("failed to run pipeline: %w", err)
|
||||||
|
}
|
||||||
|
if !statsFound {
|
||||||
|
return stats, fmt.Errorf("failed to retrieve stream stats: no stats received")
|
||||||
|
}
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
166
server/ingest/ingest.go
Normal file
166
server/ingest/ingest.go
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
package ingest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/url"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.koval.net/cyclane/cctv/server/models"
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/store"
|
||||||
|
"golang.org/x/sync/errgroup"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Ingest struct {
|
||||||
|
sync.WaitGroup
|
||||||
|
app core.App
|
||||||
|
log *slog.Logger
|
||||||
|
pipelines *store.Store[string, *activePipeline]
|
||||||
|
}
|
||||||
|
|
||||||
|
type activePipeline struct {
|
||||||
|
stream *models.Stream
|
||||||
|
pipeline *CameraPipeline
|
||||||
|
cancel context.CancelFunc
|
||||||
|
terminated <-chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func BeginIngest(ctx context.Context, app core.App) *Ingest {
|
||||||
|
log := app.Logger().With("svc", "ingest")
|
||||||
|
ingest := &Ingest{
|
||||||
|
WaitGroup: sync.WaitGroup{},
|
||||||
|
app: app,
|
||||||
|
log: log,
|
||||||
|
pipelines: store.New[string, *activePipeline](nil),
|
||||||
|
}
|
||||||
|
|
||||||
|
// initialise thumbnail pipelines
|
||||||
|
thumbnailStreams := map[string]*models.Stream{}
|
||||||
|
streams := []*models.Stream{}
|
||||||
|
err := app.RunInTransaction(func(txApp core.App) error {
|
||||||
|
if err := txApp.RecordQuery("streams").All(&streams); err != nil {
|
||||||
|
return fmt.Errorf("failed to fetch stream records: %w", err)
|
||||||
|
}
|
||||||
|
for _, stream := range streams {
|
||||||
|
other, ok := thumbnailStreams[stream.CameraID()]
|
||||||
|
if !ok || thumbnailStreamScore(stream) < thumbnailStreamScore(other) {
|
||||||
|
thumbnailStreams[stream.CameraID()] = stream
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
streamRecords := make([]*core.Record, len(streams))
|
||||||
|
for i, stream := range streams {
|
||||||
|
streamRecords[i] = stream.Record
|
||||||
|
}
|
||||||
|
if errs := txApp.ExpandRecords(streamRecords, []string{"camera"}, nil); len(errs) > 0 {
|
||||||
|
return fmt.Errorf("failed to expand camera relation for stream records: %v", errs)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Failed to initialize stream pipelines", "error", err)
|
||||||
|
return ingest
|
||||||
|
}
|
||||||
|
|
||||||
|
group := errgroup.Group{}
|
||||||
|
for _, stream := range streams {
|
||||||
|
group.Go(func() error {
|
||||||
|
log := log.With("stream", stream.Id)
|
||||||
|
rtspUrl, err := url.Parse(stream.URL())
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to parse stream URL for stream %s: %w", stream.Id, err)
|
||||||
|
}
|
||||||
|
password, err := stream.Camera().Password(app.EncryptionEnv())
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to decrypt camera password for stream %s: %w", stream.Id, err)
|
||||||
|
}
|
||||||
|
rtspUrl.User = url.UserPassword(stream.Camera().Username(), password)
|
||||||
|
pipeline, err := CreatePipeline(log)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create pipeline for stream %s: %w", stream.Id, err)
|
||||||
|
}
|
||||||
|
if err := pipeline.addRtspSource(rtspUrl); err != nil {
|
||||||
|
return fmt.Errorf("failed to add RTSP source to pipeline for stream %s: %w", stream.Id, err)
|
||||||
|
}
|
||||||
|
consumers := false
|
||||||
|
if thumbnailStream, ok := thumbnailStreams[stream.CameraID()]; ok && thumbnailStream.Id == stream.Id {
|
||||||
|
consumers = true
|
||||||
|
if err := pipeline.addThumbnailBranch(480, 270, 1000*time.Millisecond, 20*time.Second); err != nil {
|
||||||
|
return fmt.Errorf("failed to add thumbnail branch to pipeline for stream %s: %w", stream.Id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := pipeline.addLiveBranch(20 * time.Second); err != nil {
|
||||||
|
return fmt.Errorf("failed to add live branch to pipeline for stream %s: %w", stream.Id, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if consumers {
|
||||||
|
log.Info("Starting pipeline for stream with consumers.")
|
||||||
|
} else {
|
||||||
|
log.Info("Stream has no consumers, ignoring pipeline.")
|
||||||
|
pipeline.Close()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
pipelineCtx, cancel := context.WithCancel(ctx)
|
||||||
|
terminated := make(chan struct{})
|
||||||
|
ingest.pipelines.Set(stream.Id, &activePipeline{
|
||||||
|
stream: stream,
|
||||||
|
pipeline: pipeline,
|
||||||
|
cancel: cancel,
|
||||||
|
terminated: terminated,
|
||||||
|
})
|
||||||
|
ingest.WaitGroup.Go(func() {
|
||||||
|
if err := pipeline.Run(pipelineCtx); err != nil {
|
||||||
|
log.Error("Stream pipeline exited with error", "error", err)
|
||||||
|
}
|
||||||
|
close(terminated)
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err := group.Wait(); err != nil {
|
||||||
|
log.Error("Failed to start ingest pipelines", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ingest
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ingest *Ingest) GetThumbnail(ctx context.Context, streamId string) (*bytes.Reader, error) {
|
||||||
|
active, ok := ingest.pipelines.GetOk(streamId)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("no active pipeline for stream %s", streamId)
|
||||||
|
}
|
||||||
|
thumb, err := active.pipeline.GetThumbnail(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get thumbnail from pipeline for stream %s: %w", streamId, err)
|
||||||
|
}
|
||||||
|
return thumb, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func thumbnailStreamScore(stream *models.Stream) float64 {
|
||||||
|
return stream.FPS() * float64(stream.Height()*stream.Width())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ingest *Ingest) SubscribeLive(ctx context.Context, streamId string) (<-chan *bytes.Reader, error) {
|
||||||
|
active, ok := ingest.pipelines.GetOk(streamId)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("no active pipeline for stream %s", streamId)
|
||||||
|
}
|
||||||
|
id, stream, err := active.pipeline.LiveSubscribe(1*time.Second, 16)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to subscribe to live stream %s: %w", streamId, err)
|
||||||
|
}
|
||||||
|
defer active.pipeline.LiveUnsubscribe(id)
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, nil
|
||||||
|
case buf := <-stream:
|
||||||
|
ingest.log.Debug("Received live stream chunk", "streamId", streamId, "chunkSize", buf.Len())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
323
server/ingest/pipeline.go
Normal file
323
server/ingest/pipeline.go
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
package ingest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/url"
|
||||||
|
"reflect"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-gst/go-glib/glib"
|
||||||
|
"github.com/go-gst/go-gst/gst"
|
||||||
|
"github.com/go-gst/go-gst/gst/app"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CameraPipeline struct {
|
||||||
|
log *slog.Logger
|
||||||
|
pipeline *gst.Pipeline
|
||||||
|
|
||||||
|
vTee *gst.Element
|
||||||
|
aTee *gst.Element
|
||||||
|
|
||||||
|
thumbnailBlockPad *gst.Pad
|
||||||
|
thumbnailSink *app.Sink
|
||||||
|
thumbnailBlockProbe uint64
|
||||||
|
thumbnailBlocked atomic.Bool
|
||||||
|
thumbnailTimeout time.Duration
|
||||||
|
thumbnailReq chan thumbnailReq
|
||||||
|
|
||||||
|
liveVValve *gst.Element
|
||||||
|
liveAValve *gst.Element
|
||||||
|
liveSink *app.Sink
|
||||||
|
liveTimeout time.Duration
|
||||||
|
liveSubscribe chan subscribeReq
|
||||||
|
liveUnsubscribe chan unsubscribeReq
|
||||||
|
}
|
||||||
|
|
||||||
|
type jobResult struct {
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreatePipeline(log *slog.Logger) (*CameraPipeline, error) {
|
||||||
|
var err error
|
||||||
|
p := &CameraPipeline{
|
||||||
|
log: log,
|
||||||
|
}
|
||||||
|
p.pipeline, err = gst.NewPipeline("")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create GStreamer pipeline: %w", err)
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *CameraPipeline) Close() {
|
||||||
|
p.pipeline.SetState(gst.StateNull)
|
||||||
|
|
||||||
|
p.thumbnailBlockPad = nil
|
||||||
|
p.thumbnailSink = nil
|
||||||
|
|
||||||
|
p.liveVValve = nil
|
||||||
|
p.liveAValve = nil
|
||||||
|
p.liveSink = nil
|
||||||
|
|
||||||
|
p.vTee = nil
|
||||||
|
p.aTee = nil
|
||||||
|
|
||||||
|
p.pipeline = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *CameraPipeline) Run(ctx context.Context) error {
|
||||||
|
if err := p.pipeline.SetState(gst.StatePlaying); err != nil {
|
||||||
|
return fmt.Errorf("failed to set pipeline to playing: %w", err)
|
||||||
|
}
|
||||||
|
if p.thumbnailSink != nil {
|
||||||
|
go p.thumbnailManager(ctx)
|
||||||
|
}
|
||||||
|
if p.liveSink != nil {
|
||||||
|
go p.liveManager(ctx)
|
||||||
|
}
|
||||||
|
bus := p.pipeline.GetBus()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
p.Close()
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
done, err := handleMessage(p.log, bus.TimedPop(gst.ClockTime((500 * time.Millisecond).Nanoseconds())))
|
||||||
|
if err != nil {
|
||||||
|
p.Close()
|
||||||
|
return fmt.Errorf("pipeline error: %w", err)
|
||||||
|
}
|
||||||
|
if done {
|
||||||
|
p.Close()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleMessage(log *slog.Logger, msg *gst.Message) (bool, error) {
|
||||||
|
if msg == nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
// nolint:exhaustive
|
||||||
|
switch msg.Type() {
|
||||||
|
case gst.MessageEOS:
|
||||||
|
log.Info("Pipeline reached EOS")
|
||||||
|
return true, nil
|
||||||
|
case gst.MessageError:
|
||||||
|
err := msg.ParseError()
|
||||||
|
return true, fmt.Errorf("pipeline error: %w", err)
|
||||||
|
default:
|
||||||
|
log.Debug(fmt.Sprintf("Pipeline message: %s", msg))
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// addRtspSource populates the pipeline with:
|
||||||
|
//
|
||||||
|
// rtspsrc
|
||||||
|
// |---> (dynamic video depayloader + parser) --> vTee
|
||||||
|
// |---> (dynamic audio depayloader + parser) --> aTee
|
||||||
|
func (p *CameraPipeline) addRtspSource(rtspURL *url.URL) error {
|
||||||
|
if p.vTee != nil || p.aTee != nil {
|
||||||
|
return fmt.Errorf("source already added")
|
||||||
|
}
|
||||||
|
|
||||||
|
src, err := gst.NewElement("rtspsrc")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create rtspsrc element: %w", err)
|
||||||
|
}
|
||||||
|
if err := src.SetProperty("location", rtspURL.String()); err != nil {
|
||||||
|
return fmt.Errorf("failed to set rtspsrc location property: %w", err)
|
||||||
|
}
|
||||||
|
if err := src.SetProperty("latency", uint(200)); err != nil {
|
||||||
|
return fmt.Errorf("failed to set rtspsrc latency property: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
p.vTee, err = gst.NewElement("tee")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create video tee element: %w", err)
|
||||||
|
}
|
||||||
|
if err := p.vTee.SetProperty("name", "video_tee"); err != nil {
|
||||||
|
return fmt.Errorf("failed to set video tee name property: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
p.aTee, err = gst.NewElement("tee")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create audio tee element: %w", err)
|
||||||
|
}
|
||||||
|
if err := p.aTee.SetProperty("name", "audio_tee"); err != nil {
|
||||||
|
return fmt.Errorf("failed to set audio tee name property: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := p.pipeline.AddMany(src, p.vTee, p.aTee); err != nil {
|
||||||
|
return fmt.Errorf("failed to add elements to pipeline: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dynamically link the appropriate depayloader
|
||||||
|
src.Connect("pad-added", func(element *gst.Element, pad *gst.Pad) {
|
||||||
|
log := p.log.With("src", element.GetName(), "pad", pad.GetName())
|
||||||
|
caps := pad.GetCurrentCaps()
|
||||||
|
if caps == nil || caps.GetSize() == 0 {
|
||||||
|
log.Error("Ignoring pad (no or empty caps)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if caps.GetSize() > 1 {
|
||||||
|
// Note: should be impossible
|
||||||
|
log.Warn("Pad has multiple structures, using the first one", "caps", caps.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
structure := caps.GetStructureAt(0)
|
||||||
|
if structure == nil || structure.Name() != "application/x-rtp" {
|
||||||
|
log.Error("Ignoring pad (not RTP)", "caps", caps.String())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaRaw, err := structure.GetValue("media")
|
||||||
|
media, ok := mediaRaw.(string)
|
||||||
|
if err != nil || !ok {
|
||||||
|
log.Error("Ignoring pad (no media field)", "caps", caps.String())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
encodingRaw, err := structure.GetValue("encoding-name")
|
||||||
|
encoding, ok := encodingRaw.(string)
|
||||||
|
if err != nil || !ok {
|
||||||
|
log.Error("Ignoring pad (no encoding-name field)", "caps", caps.String())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var chain []*gst.Element
|
||||||
|
switch media {
|
||||||
|
case "video":
|
||||||
|
switch encoding {
|
||||||
|
case "H264":
|
||||||
|
chain, err = p.addStaticChain(pad, p.vTee.GetStaticPad("sink"), "rtph264depay", "h264parse")
|
||||||
|
case "H265":
|
||||||
|
chain, err = p.addStaticChain(pad, p.vTee.GetStaticPad("sink"), "rtph265depay", "h265parse")
|
||||||
|
default:
|
||||||
|
log.Error("Ignoring video pad (unsupported encoding)", "caps", caps.String())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case "audio":
|
||||||
|
switch encoding {
|
||||||
|
case "MPEG4-GENERIC":
|
||||||
|
chain, err = p.addStaticChain(pad, p.aTee.GetStaticPad("sink"), "rtpmp4gdepay", "aacparse")
|
||||||
|
default:
|
||||||
|
log.Error("Ignoring audio pad (unsupported encoding)", "caps", caps.String())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
log.Error("Ignoring pad (unsupported media)", "caps", caps.String())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Failed to add depayloader", "caps", caps.String(), "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, element := range chain {
|
||||||
|
element.SyncStateWithParent()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *CameraPipeline) addStaticChain(src *gst.Pad, sink *gst.Pad, chain ...string) ([]*gst.Element, error) {
|
||||||
|
factories := make([]elementFactory, len(chain))
|
||||||
|
for i, name := range chain {
|
||||||
|
factories[i] = elementFactory{name: name, props: nil}
|
||||||
|
}
|
||||||
|
return p.addStaticChainProps(src, sink, factories...)
|
||||||
|
}
|
||||||
|
|
||||||
|
type elementFactory struct {
|
||||||
|
name string
|
||||||
|
props map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *CameraPipeline) addStaticChainProps(src *gst.Pad, sink *gst.Pad, chain ...elementFactory) ([]*gst.Element, error) {
|
||||||
|
chainElements := make([]*gst.Element, len(chain))
|
||||||
|
last := src
|
||||||
|
for i, factory := range chain {
|
||||||
|
elem, err := gst.NewElement(factory.name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create %s: %w", factory.name, err)
|
||||||
|
}
|
||||||
|
for propName, propValue := range factory.props {
|
||||||
|
if err := elem.SetProperty(propName, propValue); err != nil {
|
||||||
|
// try to set enum property
|
||||||
|
valueErr := setGlibValueProperty(elem, propName, propValue)
|
||||||
|
if valueErr != nil {
|
||||||
|
p.log.Warn("Failed to set property glib value", "name", propName, "value", propValue, "error", valueErr)
|
||||||
|
return nil, fmt.Errorf("failed to set property %s on %s: %w", propName, factory.name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := p.pipeline.Add(elem); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to add %s to pipeline: %w", factory.name, err)
|
||||||
|
}
|
||||||
|
if last != nil {
|
||||||
|
if res := last.Link(elem.GetStaticPad("sink")); res != gst.PadLinkOK {
|
||||||
|
return nil, fmt.Errorf("failed to link %s to src: %s", factory.name, res)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
last = elem.GetStaticPad("src")
|
||||||
|
chainElements[i] = elem
|
||||||
|
}
|
||||||
|
if sink != nil {
|
||||||
|
if res := last.Link(sink); res != gst.PadLinkOK {
|
||||||
|
return nil, fmt.Errorf("failed to link depayloader chain to sink: %s", res)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return chainElements, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func gstBufferToBytes(buf *gst.Buffer) ([]byte, error) {
|
||||||
|
mapInfo := buf.Map(gst.MapRead)
|
||||||
|
if mapInfo == nil {
|
||||||
|
return nil, fmt.Errorf("failed to map buffer")
|
||||||
|
}
|
||||||
|
defer buf.Unmap()
|
||||||
|
|
||||||
|
data := make([]byte, mapInfo.Size())
|
||||||
|
if _, err := io.ReadFull(mapInfo.Reader(), data); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read buffer data: %w", err)
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func setGlibValueProperty(elem *gst.Element, name string, value any) error {
|
||||||
|
propType, err := elem.GetPropertyType(name)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("get property type %s: %w", name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
v, err := glib.ValueInit(propType)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("init gvalue for %s: %w", name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
val := reflect.ValueOf(value)
|
||||||
|
switch {
|
||||||
|
case propType.IsA(glib.TYPE_ENUM) && val.CanInt():
|
||||||
|
v.SetEnum(int(val.Int())) // safe because these config values are all done locally and guaranteed to be within int32
|
||||||
|
case propType.IsA(glib.TYPE_ENUM) && val.CanUint():
|
||||||
|
v.SetEnum(int(val.Uint())) // safe because these config values are all done locally and guaranteed to be within int32
|
||||||
|
case propType.IsA(glib.TYPE_FLAGS) && val.CanInt():
|
||||||
|
v.SetFlags(uint(val.Int())) // safe because these config values are all done locally and guaranteed to be within uint32
|
||||||
|
case propType.IsA(glib.TYPE_FLAGS) && val.CanUint():
|
||||||
|
v.SetFlags(uint(val.Uint())) // safe because these config values are all done locally and guaranteed to be within uint32
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unsupported property type for %s: %T (kind = %s), expected %s", name, value, val.Kind(), propType.Name())
|
||||||
|
}
|
||||||
|
if err := elem.SetPropertyValue(name, v); err != nil {
|
||||||
|
return fmt.Errorf("failed to set glib value property %s: %w", name, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
140
server/main.go
Normal file
140
server/main.go
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
// cctv server entrypoint
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"log/slog"
|
||||||
|
"net/http/httputil"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/go-gst/go-gst/gst"
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
"github.com/pocketbase/pocketbase"
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
"github.com/pocketbase/pocketbase/plugins/migratecmd"
|
||||||
|
|
||||||
|
"git.koval.net/cyclane/cctv/server/config"
|
||||||
|
"git.koval.net/cyclane/cctv/server/ingest"
|
||||||
|
_ "git.koval.net/cyclane/cctv/server/migrations"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ingestService *ingest.Ingest
|
||||||
|
)
|
||||||
|
|
||||||
|
func loadEnvFile(filenames ...string) {
|
||||||
|
for _, filename := range filenames {
|
||||||
|
if err := godotenv.Load(filename); err == nil {
|
||||||
|
log.Printf("Loaded environment variables from %s", filename)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func exportCollectionsJSON(e *core.BootstrapEvent) error {
|
||||||
|
if err := e.Next(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
collections, err := e.App.FindAllCollections()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to export collections: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.OpenFile("collections.json", os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to open collections.json: %w", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err := f.Close(); err != nil {
|
||||||
|
log.Printf("Failed to close collections.json: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
encoder := json.NewEncoder(f)
|
||||||
|
encoder.SetIndent("", "\t")
|
||||||
|
if err := encoder.Encode(collections); err != nil {
|
||||||
|
return fmt.Errorf("failed to write collections to JSON: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
loadEnvFile(".env.local", ".env", ".env.default")
|
||||||
|
config, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to load configuration: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
app := pocketbase.NewWithConfig(pocketbase.Config{
|
||||||
|
DefaultDev: config.IsDev(),
|
||||||
|
DefaultDataDir: config.DbDataDir,
|
||||||
|
DefaultEncryptionEnv: config.DbEncryptionKey,
|
||||||
|
})
|
||||||
|
|
||||||
|
migratecmd.MustRegister(app, app.RootCmd, migratecmd.Config{
|
||||||
|
// enable auto creation of migration files when making collection changes in the Dashboard
|
||||||
|
Automigrate: config.IsDev(),
|
||||||
|
})
|
||||||
|
|
||||||
|
if config.IsDev() {
|
||||||
|
app.OnBootstrap().BindFunc(exportCollectionsJSON)
|
||||||
|
}
|
||||||
|
var proxy *httputil.ReverseProxy
|
||||||
|
if config.ExternalWebApp != "" {
|
||||||
|
proxyURL, err := url.Parse(config.ExternalWebApp)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to parse external web app URL: %v", err)
|
||||||
|
}
|
||||||
|
proxy = httputil.NewSingleHostReverseProxy(proxyURL)
|
||||||
|
proxy.ErrorLog = slog.NewLogLogger(app.Logger().With("svc", "proxy").Handler(), slog.LevelDebug)
|
||||||
|
}
|
||||||
|
ingestCtx, cancelIngestCtx := context.WithCancel(context.Background())
|
||||||
|
app.OnServe().BindFunc(func(se *core.ServeEvent) error {
|
||||||
|
gst.Init(nil)
|
||||||
|
ingestService = ingest.BeginIngest(ingestCtx, app)
|
||||||
|
|
||||||
|
if !config.IsDev() {
|
||||||
|
se.Router.BindFunc(func(e *core.RequestEvent) error {
|
||||||
|
if strings.HasPrefix(e.Request.URL.Path, "/_/") {
|
||||||
|
return e.NotFoundError("Page not found", nil)
|
||||||
|
} else {
|
||||||
|
return e.Next()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if proxy == nil {
|
||||||
|
// TODO: serve bundled (static) files
|
||||||
|
} else {
|
||||||
|
se.Router.GET("/{path...}", func(e *core.RequestEvent) error {
|
||||||
|
proxy.ServeHTTP(e.Response, e.Request)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
registerAPI(se)
|
||||||
|
|
||||||
|
return se.Next()
|
||||||
|
})
|
||||||
|
|
||||||
|
app.OnTerminate().BindFunc(func(e *core.TerminateEvent) error {
|
||||||
|
app.Logger().Info("Shutting down ingest service")
|
||||||
|
cancelIngestCtx()
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
app.OnRecordCreateExecute().BindFunc(handleCameraUpdate)
|
||||||
|
app.OnRecordUpdateExecute().BindFunc(handleCameraUpdate)
|
||||||
|
|
||||||
|
if err := app.Start(); err != nil {
|
||||||
|
app.Logger().Info("Application terminated with error")
|
||||||
|
cancelIngestCtx()
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
ingestService.Wait()
|
||||||
|
}
|
||||||
19
server/migrations/1754019877_delete_default_users.go
Normal file
19
server/migrations/1754019877_delete_default_users.go
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
m "github.com/pocketbase/pocketbase/migrations"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
m.Register(func(app core.App) error {
|
||||||
|
collection, err := app.FindCollectionByNameOrId("users")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return app.Delete(collection)
|
||||||
|
}, func(_ core.App) error {
|
||||||
|
// eh...
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
24
server/migrations/1754019890_users.go
Normal file
24
server/migrations/1754019890_users.go
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
m "github.com/pocketbase/pocketbase/migrations"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
colUsers = "users"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
m.Register(func(app core.App) error {
|
||||||
|
users := core.NewAuthCollection(colUsers)
|
||||||
|
users.Fields.Add(createdUpdatedFields()...)
|
||||||
|
return app.Save(users)
|
||||||
|
}, func(app core.App) error {
|
||||||
|
users, err := app.FindCollectionByNameOrId(colUsers)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return app.Delete(users)
|
||||||
|
})
|
||||||
|
}
|
||||||
102
server/migrations/1754091129_cameras.go
Normal file
102
server/migrations/1754091129_cameras.go
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.koval.net/cyclane/cctv/server/models"
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
m "github.com/pocketbase/pocketbase/migrations"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
colCameras = "cameras"
|
||||||
|
colStreams = "streams"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
m.Register(func(app core.App) error {
|
||||||
|
cameras := core.NewBaseCollection(colCameras)
|
||||||
|
cameras.Fields.Add(
|
||||||
|
&core.TextField{
|
||||||
|
Name: "name",
|
||||||
|
Required: true,
|
||||||
|
},
|
||||||
|
&core.TextField{
|
||||||
|
Name: "onvif_host",
|
||||||
|
},
|
||||||
|
&core.TextField{
|
||||||
|
Name: "username",
|
||||||
|
Required: true,
|
||||||
|
},
|
||||||
|
&core.TextField{
|
||||||
|
Name: models.CameraFPassword,
|
||||||
|
Required: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
cameras.Fields.Add(createdUpdatedFields()...)
|
||||||
|
cameras.CreateRule = types.Pointer("@request.auth.id != \"\"")
|
||||||
|
cameras.ViewRule = types.Pointer("@request.auth.id != \"\"")
|
||||||
|
cameras.ListRule = types.Pointer("@request.auth.id != \"\"")
|
||||||
|
if err := app.Save(cameras); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
streams := core.NewBaseCollection(colStreams)
|
||||||
|
streams.Fields.Add(
|
||||||
|
&core.RelationField{
|
||||||
|
Name: models.StreamFCamera,
|
||||||
|
Required: true,
|
||||||
|
CascadeDelete: true,
|
||||||
|
CollectionId: cameras.Id,
|
||||||
|
},
|
||||||
|
&core.TextField{
|
||||||
|
Name: "url",
|
||||||
|
Required: true,
|
||||||
|
},
|
||||||
|
&core.NumberField{
|
||||||
|
Name: "fps",
|
||||||
|
Required: true,
|
||||||
|
Min: types.Pointer[float64](0),
|
||||||
|
},
|
||||||
|
&core.NumberField{
|
||||||
|
Name: "height",
|
||||||
|
Required: true,
|
||||||
|
OnlyInt: true,
|
||||||
|
Min: types.Pointer[float64](0),
|
||||||
|
},
|
||||||
|
&core.NumberField{
|
||||||
|
Name: "width",
|
||||||
|
Required: true,
|
||||||
|
OnlyInt: true,
|
||||||
|
Min: types.Pointer[float64](0),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
streams.ViewRule = types.Pointer("@request.auth.id != \"\"")
|
||||||
|
streams.ListRule = types.Pointer("@request.auth.id != \"\"")
|
||||||
|
if err := app.Save(streams); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cameras.Fields.Add(
|
||||||
|
&core.RelationField{
|
||||||
|
Name: models.CameraFRecordStream,
|
||||||
|
CollectionId: streams.Id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return app.Save(cameras)
|
||||||
|
}, func(app core.App) error {
|
||||||
|
cameras, err := app.FindCollectionByNameOrId(colCameras)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
streams, err := app.FindCollectionByNameOrId(colStreams)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cameras.Fields.RemoveByName(models.CameraFRecordStream)
|
||||||
|
if err := app.Save(cameras); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := app.Delete(streams); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return app.Delete(cameras)
|
||||||
|
})
|
||||||
|
}
|
||||||
17
server/migrations/utils.go
Normal file
17
server/migrations/utils.go
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
package migrations
|
||||||
|
|
||||||
|
import "github.com/pocketbase/pocketbase/core"
|
||||||
|
|
||||||
|
func createdUpdatedFields() []core.Field {
|
||||||
|
return []core.Field{
|
||||||
|
&core.AutodateField{
|
||||||
|
Name: "created",
|
||||||
|
OnCreate: true,
|
||||||
|
},
|
||||||
|
&core.AutodateField{
|
||||||
|
Name: "updated",
|
||||||
|
OnCreate: true,
|
||||||
|
OnUpdate: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
63
server/models/cameras.go
Normal file
63
server/models/cameras.go
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/security"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
CameraFPassword = "password"
|
||||||
|
CameraFRecordStream = "record_stream"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Camera struct {
|
||||||
|
core.BaseRecordProxy
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Camera) Name() string {
|
||||||
|
return c.GetString("name")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Camera) OnvifHost() string {
|
||||||
|
return c.GetString("onvif_host")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Camera) Username() string {
|
||||||
|
return c.GetString("username")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Camera) PasswordRaw() string {
|
||||||
|
return c.GetString(CameraFPassword)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Camera) Password(key string) (string, error) {
|
||||||
|
password, err := security.Decrypt(c.PasswordRaw(), key)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to decrypt password: %w", err)
|
||||||
|
}
|
||||||
|
return string(password), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Camera) SetPassword(password string, key string) error {
|
||||||
|
encrypted, err := security.Encrypt([]byte(password), key)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to encrypt password: %w", err)
|
||||||
|
}
|
||||||
|
c.Set(CameraFPassword, encrypted)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Camera) RecordStreamID() string {
|
||||||
|
return c.GetString(CameraFRecordStream)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Camera) RecordStream() *Stream {
|
||||||
|
if r := c.ExpandedOne(CameraFRecordStream); r != nil {
|
||||||
|
stream := &Stream{}
|
||||||
|
stream.SetProxyRecord(r)
|
||||||
|
return stream
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
60
server/models/streams.go
Normal file
60
server/models/streams.go
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "github.com/pocketbase/pocketbase/core"
|
||||||
|
|
||||||
|
const (
|
||||||
|
StreamFCamera = "camera"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Stream struct {
|
||||||
|
core.BaseRecordProxy
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Stream) CameraID() string {
|
||||||
|
return s.GetString(StreamFCamera)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Stream) SetCameraID(id string) {
|
||||||
|
s.Set("camera", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Stream) Camera() *Camera {
|
||||||
|
if r := s.ExpandedOne(StreamFCamera); r != nil {
|
||||||
|
camera := &Camera{}
|
||||||
|
camera.SetProxyRecord(r)
|
||||||
|
return camera
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Stream) URL() string {
|
||||||
|
return s.GetString("url")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Stream) SetURL(url string) {
|
||||||
|
s.Set("url", url)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Stream) FPS() float64 {
|
||||||
|
return s.GetFloat("fps")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Stream) SetFPS(fps float64) {
|
||||||
|
s.Set("fps", fps)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Stream) Height() int {
|
||||||
|
return s.GetInt("height")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Stream) SetHeight(height int) {
|
||||||
|
s.Set("height", height)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Stream) Width() int {
|
||||||
|
return s.GetInt("width")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Stream) SetWidth(width int) {
|
||||||
|
s.Set("width", width)
|
||||||
|
}
|
||||||
1
web/.env.example
Normal file
1
web/.env.example
Normal file
@@ -0,0 +1 @@
|
|||||||
|
PUBLIC_PROD_AUTH_STORE=false
|
||||||
1
web/.eslintignore
Normal file
1
web/.eslintignore
Normal file
@@ -0,0 +1 @@
|
|||||||
|
src/lib/pocketbase-types.ts
|
||||||
28
web/.gitignore
vendored
Normal file
28
web/.gitignore
vendored
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
node_modules
|
||||||
|
|
||||||
|
# Output
|
||||||
|
.output
|
||||||
|
.vercel
|
||||||
|
.netlify
|
||||||
|
.wrangler
|
||||||
|
/.svelte-kit
|
||||||
|
/build
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Env
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
!.env.test
|
||||||
|
|
||||||
|
# Vite
|
||||||
|
vite.config.js.timestamp-*
|
||||||
|
vite.config.ts.timestamp-*
|
||||||
|
# Playwright
|
||||||
|
test-results
|
||||||
|
|
||||||
|
*storybook.log
|
||||||
|
storybook-static
|
||||||
1
web/.npmrc
Normal file
1
web/.npmrc
Normal file
@@ -0,0 +1 @@
|
|||||||
|
engine-strict=true
|
||||||
10
web/.prettierignore
Normal file
10
web/.prettierignore
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# Package Managers
|
||||||
|
package-lock.json
|
||||||
|
pnpm-lock.yaml
|
||||||
|
yarn.lock
|
||||||
|
bun.lock
|
||||||
|
bun.lockb
|
||||||
|
|
||||||
|
# Miscellaneous
|
||||||
|
/static/
|
||||||
|
src/lib/pocketbase-types.ts
|
||||||
16
web/.prettierrc
Normal file
16
web/.prettierrc
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"useTabs": true,
|
||||||
|
"singleQuote": false,
|
||||||
|
"trailingComma": "none",
|
||||||
|
"printWidth": 100,
|
||||||
|
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": "*.svelte",
|
||||||
|
"options": {
|
||||||
|
"parser": "svelte"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tailwindStylesheet": "./src/routes/layout.css"
|
||||||
|
}
|
||||||
14
web/.storybook/main.ts
Normal file
14
web/.storybook/main.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import type { StorybookConfig } from "@storybook/sveltekit";
|
||||||
|
|
||||||
|
const config: StorybookConfig = {
|
||||||
|
stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|ts|svelte)"],
|
||||||
|
addons: [
|
||||||
|
"@storybook/addon-svelte-csf",
|
||||||
|
"@chromatic-com/storybook",
|
||||||
|
"@storybook/addon-vitest",
|
||||||
|
"@storybook/addon-a11y",
|
||||||
|
"@storybook/addon-docs"
|
||||||
|
],
|
||||||
|
framework: "@storybook/sveltekit"
|
||||||
|
};
|
||||||
|
export default config;
|
||||||
21
web/.storybook/preview.ts
Normal file
21
web/.storybook/preview.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { Preview } from "@storybook/sveltekit";
|
||||||
|
|
||||||
|
const preview: Preview = {
|
||||||
|
parameters: {
|
||||||
|
controls: {
|
||||||
|
matchers: {
|
||||||
|
color: /(background|color)$/i,
|
||||||
|
date: /Date$/i
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
a11y: {
|
||||||
|
// 'todo' - show a11y violations in the test UI only
|
||||||
|
// 'error' - fail CI on a11y violations
|
||||||
|
// 'off' - skip a11y checks entirely
|
||||||
|
test: "todo"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default preview;
|
||||||
7
web/.vscode/settings.json
vendored
Normal file
7
web/.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
|
"editor.formatOnSave": true,
|
||||||
|
"editor.codeActionsOnSave": {
|
||||||
|
"source.organizeImports": "always"
|
||||||
|
}
|
||||||
|
}
|
||||||
20
web/components.json
Normal file
20
web/components.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://shadcn-svelte.com/schema.json",
|
||||||
|
"tailwind": {
|
||||||
|
"css": "src/routes/layout.css",
|
||||||
|
"baseColor": "mist"
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "$lib/components",
|
||||||
|
"utils": "$lib/utils",
|
||||||
|
"ui": "$lib/components/ui",
|
||||||
|
"hooks": "$lib/hooks",
|
||||||
|
"lib": "$lib"
|
||||||
|
},
|
||||||
|
"typescript": true,
|
||||||
|
"registry": "https://shadcn-svelte.com/registry",
|
||||||
|
"style": "luma",
|
||||||
|
"iconLibrary": "remixicon",
|
||||||
|
"menuColor": "default",
|
||||||
|
"menuAccent": "bold"
|
||||||
|
}
|
||||||
42
web/eslint.config.js
Normal file
42
web/eslint.config.js
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
// For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format
|
||||||
|
import storybook from "eslint-plugin-storybook";
|
||||||
|
|
||||||
|
import prettier from "eslint-config-prettier";
|
||||||
|
import path from "node:path";
|
||||||
|
import { includeIgnoreFile } from "@eslint/compat";
|
||||||
|
import js from "@eslint/js";
|
||||||
|
import svelte from "eslint-plugin-svelte";
|
||||||
|
import { defineConfig } from "eslint/config";
|
||||||
|
import globals from "globals";
|
||||||
|
import ts from "typescript-eslint";
|
||||||
|
import svelteConfig from "./svelte.config.js";
|
||||||
|
|
||||||
|
const gitignorePath = path.resolve(import.meta.dirname, ".gitignore");
|
||||||
|
|
||||||
|
export default defineConfig(
|
||||||
|
includeIgnoreFile(gitignorePath),
|
||||||
|
js.configs.recommended,
|
||||||
|
ts.configs.recommended,
|
||||||
|
svelte.configs.recommended,
|
||||||
|
prettier,
|
||||||
|
svelte.configs.prettier,
|
||||||
|
{
|
||||||
|
languageOptions: { globals: { ...globals.browser, ...globals.node } },
|
||||||
|
rules: {
|
||||||
|
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
|
||||||
|
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
|
||||||
|
"no-undef": "off"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ["**/*.svelte", "**/*.svelte.ts", "**/*.svelte.js"],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
projectService: true,
|
||||||
|
extraFileExtensions: [".svelte"],
|
||||||
|
parser: ts.parser,
|
||||||
|
svelteConfig
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
8100
web/package-lock.json
generated
Normal file
8100
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
75
web/package.json
Normal file
75
web/package.json
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
{
|
||||||
|
"name": "web",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.1",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite dev",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"prepare": "svelte-kit sync || echo ''",
|
||||||
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
|
"lint": "prettier --check . && eslint .",
|
||||||
|
"format": "prettier --write .",
|
||||||
|
"test:unit": "vitest",
|
||||||
|
"test": "npm run test:unit -- --run && npm run test:e2e",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
|
"storybook": "storybook dev -p 6006",
|
||||||
|
"build-storybook": "storybook build",
|
||||||
|
"gen:types": "pocketbase-typegen -j ../server/collections.json -o ./src/lib/pocketbase-types.ts"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@chromatic-com/storybook": "^5.1.1",
|
||||||
|
"@eslint/compat": "^2.0.3",
|
||||||
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@fontsource-variable/outfit": "^5.2.8",
|
||||||
|
"@internationalized/date": "^3.12.0",
|
||||||
|
"@playwright/test": "^1.58.2",
|
||||||
|
"@storybook/addon-a11y": "^10.3.3",
|
||||||
|
"@storybook/addon-docs": "^10.3.3",
|
||||||
|
"@storybook/addon-svelte-csf": "^5.1.2",
|
||||||
|
"@storybook/addon-vitest": "^10.3.3",
|
||||||
|
"@storybook/sveltekit": "^10.3.3",
|
||||||
|
"@sveltejs/adapter-static": "^3.0.10",
|
||||||
|
"@sveltejs/kit": "^2.50.2",
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||||
|
"@tailwindcss/forms": "^0.5.11",
|
||||||
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
|
"@types/node": "^22",
|
||||||
|
"@vitest/browser-playwright": "^4.1.0",
|
||||||
|
"@vitest/coverage-v8": "^4.1.0",
|
||||||
|
"bits-ui": "^2.16.5",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"eslint": "^10.0.3",
|
||||||
|
"eslint-config-prettier": "^10.1.8",
|
||||||
|
"eslint-plugin-storybook": "^10.3.3",
|
||||||
|
"eslint-plugin-svelte": "^3.15.2",
|
||||||
|
"globals": "^17.4.0",
|
||||||
|
"mode-watcher": "^1.1.0",
|
||||||
|
"playwright": "^1.58.2",
|
||||||
|
"pocketbase-typegen": "^1.3.3",
|
||||||
|
"prettier": "^3.8.1",
|
||||||
|
"prettier-plugin-svelte": "^3.4.1",
|
||||||
|
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||||
|
"remixicon-svelte": "^0.0.5",
|
||||||
|
"shadcn-svelte": "^1.2.6",
|
||||||
|
"storybook": "^10.3.3",
|
||||||
|
"svelte": "^5.54.0",
|
||||||
|
"svelte-check": "^4.4.2",
|
||||||
|
"svelte-sonner": "^1.1.0",
|
||||||
|
"tailwind-merge": "^3.5.0",
|
||||||
|
"tailwind-variants": "^3.2.2",
|
||||||
|
"tailwindcss": "^4.1.18",
|
||||||
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"typescript-eslint": "^8.57.0",
|
||||||
|
"vaul-svelte": "^1.0.0-next.7",
|
||||||
|
"vite": "^7.3.1",
|
||||||
|
"vitest": "^4.1.0",
|
||||||
|
"vitest-browser-svelte": "^2.0.2"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"pocketbase": "^0.26.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
web/playwright.config.ts
Normal file
6
web/playwright.config.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { defineConfig } from "@playwright/test";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
webServer: { command: "npm run build && npm run preview", port: 4173 },
|
||||||
|
testMatch: "**/*.e2e.{ts,js}"
|
||||||
|
});
|
||||||
13
web/src/app.d.ts
vendored
Normal file
13
web/src/app.d.ts
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||||
|
// for information about these interfaces
|
||||||
|
declare global {
|
||||||
|
namespace App {
|
||||||
|
// interface Error {}
|
||||||
|
// interface Locals {}
|
||||||
|
// interface PageData {}
|
||||||
|
// interface PageState {}
|
||||||
|
// interface Platform {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
11
web/src/app.html
Normal file
11
web/src/app.html
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" class="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
%sveltekit.head%
|
||||||
|
</head>
|
||||||
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1
web/src/lib/assets/favicon.svg
Normal file
1
web/src/lib/assets/favicon.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,23 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
import type { ComponentProps } from "svelte";
|
||||||
|
import { Separator } from "$lib/components/ui/separator/index.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
orientation = "vertical",
|
||||||
|
...restProps
|
||||||
|
}: ComponentProps<typeof Separator> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Separator
|
||||||
|
bind:ref
|
||||||
|
data-slot="button-group-separator"
|
||||||
|
{orientation}
|
||||||
|
class={cn(
|
||||||
|
"bg-input relative self-stretch data-[orientation=horizontal]:mx-px data-[orientation=horizontal]:w-auto data-[orientation=vertical]:my-px data-[orientation=vertical]:h-auto",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
child,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
|
||||||
|
child?: Snippet<[{ props: Record<string, unknown> }]>;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const mergedProps = $derived({
|
||||||
|
...restProps,
|
||||||
|
class: cn("bg-muted gap-2 rounded-4xl border px-2.5 text-sm font-medium [&_svg:not([class*='size-'])]:size-4 flex items-center [&_svg]:pointer-events-none", className),
|
||||||
|
"data-slot": "button-group-text",
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if child}
|
||||||
|
{@render child({ props: mergedProps })}
|
||||||
|
{:else}
|
||||||
|
<div bind:this={ref} {...mergedProps}>
|
||||||
|
{@render mergedProps.children?.()}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
46
web/src/lib/components/ui/button-group/button-group.svelte
Normal file
46
web/src/lib/components/ui/button-group/button-group.svelte
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<script lang="ts" module>
|
||||||
|
import { tv, type VariantProps } from "tailwind-variants";
|
||||||
|
|
||||||
|
export const buttonGroupVariants = tv({
|
||||||
|
base: "has-[>[data-variant=outline]]:[&>input]:border-border has-[>[data-variant=outline]]:[&>input:focus-visible]:border-ring has-[>[data-variant=outline]]:*:data-[slot=input-group]:border-border has-[>[data-variant=outline]]:[&>[data-slot=input-group]:has(:focus-visible)]:border-ring has-[>[data-variant=outline]]:*:data-[slot=select-trigger]:border-border has-[>[data-variant=outline]]:[&>[data-slot=select-trigger]:focus-visible]:border-ring has-[>[data-slot=button-group]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-4xl flex w-fit items-stretch [&>*]:focus-visible:relative [&>*]:focus-visible:z-10 [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
|
||||||
|
variants: {
|
||||||
|
orientation: {
|
||||||
|
horizontal:
|
||||||
|
"[&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-4xl! [&>[data-slot]]:rounded-r-none [&>[data-slot]~[data-slot]]:rounded-l-none [&>[data-slot]~[data-slot]]:border-l-0",
|
||||||
|
vertical:
|
||||||
|
"[&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-4xl! flex-col [&>[data-slot]]:rounded-b-none [&>[data-slot]~[data-slot]]:rounded-t-none [&>[data-slot]~[data-slot]]:border-t-0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
orientation: "horizontal",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ButtonGroupOrientation = VariantProps<typeof buttonGroupVariants>["orientation"];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
orientation = "horizontal",
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
|
||||||
|
orientation?: ButtonGroupOrientation;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
role="group"
|
||||||
|
data-slot="button-group"
|
||||||
|
data-orientation={orientation}
|
||||||
|
class={cn(buttonGroupVariants({ orientation }), className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
15
web/src/lib/components/ui/button-group/index.ts
Normal file
15
web/src/lib/components/ui/button-group/index.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import Root, { buttonGroupVariants, type ButtonGroupOrientation } from "./button-group.svelte";
|
||||||
|
import Text from "./button-group-text.svelte";
|
||||||
|
import Separator from "./button-group-separator.svelte";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
Text,
|
||||||
|
Separator,
|
||||||
|
buttonGroupVariants,
|
||||||
|
type ButtonGroupOrientation,
|
||||||
|
//
|
||||||
|
Root as ButtonGroup,
|
||||||
|
Text as ButtonGroupText,
|
||||||
|
Separator as ButtonGroupSeparator,
|
||||||
|
};
|
||||||
82
web/src/lib/components/ui/button/button.svelte
Normal file
82
web/src/lib/components/ui/button/button.svelte
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
<script lang="ts" module>
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from "svelte/elements";
|
||||||
|
import { type VariantProps, tv } from "tailwind-variants";
|
||||||
|
|
||||||
|
export const buttonVariants = tv({
|
||||||
|
base: "focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-4xl border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-3 active:not-aria-[haspopup]:translate-y-px aria-invalid:ring-3 [&_svg:not([class*='size-'])]:size-4 group/button inline-flex shrink-0 items-center justify-center whitespace-nowrap transition-all outline-none select-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground hover:bg-primary/80",
|
||||||
|
outline: "border-border bg-background hover:bg-muted hover:text-foreground dark:hover:bg-input/30 aria-expanded:bg-muted aria-expanded:text-foreground dark:bg-transparent",
|
||||||
|
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||||
|
ghost: "hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground",
|
||||||
|
destructive: "bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 gap-1.5 px-3 has-data-[icon=inline-end]:pr-2.5 has-data-[icon=inline-start]:pl-2.5",
|
||||||
|
xs: "h-6 gap-1 px-2.5 text-xs has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
sm: "h-8 gap-1 px-3 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||||
|
lg: "h-10 gap-1.5 px-4 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
|
||||||
|
icon: "size-9",
|
||||||
|
"icon-xs": "size-6 [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
"icon-sm": "size-8",
|
||||||
|
"icon-lg": "size-10",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
|
||||||
|
export type ButtonSize = VariantProps<typeof buttonVariants>["size"];
|
||||||
|
|
||||||
|
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
|
||||||
|
WithElementRef<HTMLAnchorAttributes> & {
|
||||||
|
variant?: ButtonVariant;
|
||||||
|
size?: ButtonSize;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
let {
|
||||||
|
class: className,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
ref = $bindable(null),
|
||||||
|
href = undefined,
|
||||||
|
type = "button",
|
||||||
|
disabled,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: ButtonProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if href}
|
||||||
|
<a
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="button"
|
||||||
|
class={cn(buttonVariants({ variant, size }), className)}
|
||||||
|
href={disabled ? undefined : href}
|
||||||
|
aria-disabled={disabled}
|
||||||
|
role={disabled ? "link" : undefined}
|
||||||
|
tabindex={disabled ? -1 : undefined}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="button"
|
||||||
|
class={cn(buttonVariants({ variant, size }), className)}
|
||||||
|
{type}
|
||||||
|
{disabled}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
17
web/src/lib/components/ui/button/index.ts
Normal file
17
web/src/lib/components/ui/button/index.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import Root, {
|
||||||
|
type ButtonProps,
|
||||||
|
type ButtonSize,
|
||||||
|
type ButtonVariant,
|
||||||
|
buttonVariants,
|
||||||
|
} from "./button.svelte";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
type ButtonProps as Props,
|
||||||
|
//
|
||||||
|
Root as Button,
|
||||||
|
buttonVariants,
|
||||||
|
type ButtonProps,
|
||||||
|
type ButtonSize,
|
||||||
|
type ButtonVariant,
|
||||||
|
};
|
||||||
11
web/src/lib/components/ui/dialog/dialog-close.svelte
Normal file
11
web/src/lib/components/ui/dialog/dialog-close.svelte
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
type = "button",
|
||||||
|
...restProps
|
||||||
|
}: DialogPrimitive.CloseProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DialogPrimitive.Close bind:ref data-slot="dialog-close" {type} {...restProps} />
|
||||||
48
web/src/lib/components/ui/dialog/dialog-content.svelte
Normal file
48
web/src/lib/components/ui/dialog/dialog-content.svelte
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||||
|
import DialogPortal from "./dialog-portal.svelte";
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
import * as Dialog from "./index.js";
|
||||||
|
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||||
|
import type { ComponentProps } from "svelte";
|
||||||
|
import { Button } from "$lib/components/ui/button/index.js";
|
||||||
|
import RiCloseLine from 'remixicon-svelte/icons/close-line';
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
portalProps,
|
||||||
|
children,
|
||||||
|
showCloseButton = true,
|
||||||
|
...restProps
|
||||||
|
}: WithoutChildrenOrChild<DialogPrimitive.ContentProps> & {
|
||||||
|
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof DialogPortal>>;
|
||||||
|
children: Snippet;
|
||||||
|
showCloseButton?: boolean;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DialogPortal {...portalProps}>
|
||||||
|
<Dialog.Overlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
bind:ref
|
||||||
|
data-slot="dialog-content"
|
||||||
|
class={cn(
|
||||||
|
"bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 ring-foreground/5 dark:ring-foreground/10 grid max-w-[calc(100%-2rem)] gap-6 rounded-4xl p-6 text-sm shadow-xl ring-1 duration-100 sm:max-w-md fixed top-1/2 left-1/2 z-50 w-full -translate-x-1/2 -translate-y-1/2 outline-none",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
{#if showCloseButton}
|
||||||
|
<DialogPrimitive.Close data-slot="dialog-close">
|
||||||
|
{#snippet child({ props })}
|
||||||
|
<Button variant="ghost" class="bg-secondary absolute top-4 right-4" size="icon-sm" {...props}>
|
||||||
|
<RiCloseLine />
|
||||||
|
<span class="sr-only">Close</span>
|
||||||
|
</Button>
|
||||||
|
{/snippet}
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
{/if}
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
17
web/src/lib/components/ui/dialog/dialog-description.svelte
Normal file
17
web/src/lib/components/ui/dialog/dialog-description.svelte
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: DialogPrimitive.DescriptionProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
bind:ref
|
||||||
|
data-slot="dialog-description"
|
||||||
|
class={cn("text-muted-foreground *:[a]:hover:text-foreground text-sm *:[a]:underline *:[a]:underline-offset-3", className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
32
web/src/lib/components/ui/dialog/dialog-footer.svelte
Normal file
32
web/src/lib/components/ui/dialog/dialog-footer.svelte
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||||
|
import { Button } from "$lib/components/ui/button/index.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
showCloseButton = false,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
|
||||||
|
showCloseButton?: boolean;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="dialog-footer"
|
||||||
|
class={cn("gap-2 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
{#if showCloseButton}
|
||||||
|
<DialogPrimitive.Close>
|
||||||
|
{#snippet child({ props })}
|
||||||
|
<Button variant="outline" {...props}>Close</Button>
|
||||||
|
{/snippet}
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
20
web/src/lib/components/ui/dialog/dialog-header.svelte
Normal file
20
web/src/lib/components/ui/dialog/dialog-header.svelte
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="dialog-header"
|
||||||
|
class={cn("gap-1.5 flex flex-col", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
17
web/src/lib/components/ui/dialog/dialog-overlay.svelte
Normal file
17
web/src/lib/components/ui/dialog/dialog-overlay.svelte
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: DialogPrimitive.OverlayProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
bind:ref
|
||||||
|
data-slot="dialog-overlay"
|
||||||
|
class={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/30 duration-100 supports-backdrop-filter:backdrop-blur-sm fixed inset-0 isolate z-50", className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
7
web/src/lib/components/ui/dialog/dialog-portal.svelte
Normal file
7
web/src/lib/components/ui/dialog/dialog-portal.svelte
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
let { ...restProps }: DialogPrimitive.PortalProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DialogPrimitive.Portal {...restProps} />
|
||||||
17
web/src/lib/components/ui/dialog/dialog-title.svelte
Normal file
17
web/src/lib/components/ui/dialog/dialog-title.svelte
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: DialogPrimitive.TitleProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
bind:ref
|
||||||
|
data-slot="dialog-title"
|
||||||
|
class={cn("text-base leading-none font-medium", className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
11
web/src/lib/components/ui/dialog/dialog-trigger.svelte
Normal file
11
web/src/lib/components/ui/dialog/dialog-trigger.svelte
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
type = "button",
|
||||||
|
...restProps
|
||||||
|
}: DialogPrimitive.TriggerProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DialogPrimitive.Trigger bind:ref data-slot="dialog-trigger" {type} {...restProps} />
|
||||||
7
web/src/lib/components/ui/dialog/dialog.svelte
Normal file
7
web/src/lib/components/ui/dialog/dialog.svelte
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
let { open = $bindable(false), ...restProps }: DialogPrimitive.RootProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DialogPrimitive.Root bind:open {...restProps} />
|
||||||
34
web/src/lib/components/ui/dialog/index.ts
Normal file
34
web/src/lib/components/ui/dialog/index.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import Root from "./dialog.svelte";
|
||||||
|
import Portal from "./dialog-portal.svelte";
|
||||||
|
import Title from "./dialog-title.svelte";
|
||||||
|
import Footer from "./dialog-footer.svelte";
|
||||||
|
import Header from "./dialog-header.svelte";
|
||||||
|
import Overlay from "./dialog-overlay.svelte";
|
||||||
|
import Content from "./dialog-content.svelte";
|
||||||
|
import Description from "./dialog-description.svelte";
|
||||||
|
import Trigger from "./dialog-trigger.svelte";
|
||||||
|
import Close from "./dialog-close.svelte";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
Title,
|
||||||
|
Portal,
|
||||||
|
Footer,
|
||||||
|
Header,
|
||||||
|
Trigger,
|
||||||
|
Overlay,
|
||||||
|
Content,
|
||||||
|
Description,
|
||||||
|
Close,
|
||||||
|
//
|
||||||
|
Root as Dialog,
|
||||||
|
Title as DialogTitle,
|
||||||
|
Portal as DialogPortal,
|
||||||
|
Footer as DialogFooter,
|
||||||
|
Header as DialogHeader,
|
||||||
|
Trigger as DialogTrigger,
|
||||||
|
Overlay as DialogOverlay,
|
||||||
|
Content as DialogContent,
|
||||||
|
Description as DialogDescription,
|
||||||
|
Close as DialogClose,
|
||||||
|
};
|
||||||
7
web/src/lib/components/ui/drawer/drawer-close.svelte
Normal file
7
web/src/lib/components/ui/drawer/drawer-close.svelte
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Drawer as DrawerPrimitive } from "vaul-svelte";
|
||||||
|
|
||||||
|
let { ref = $bindable(null), ...restProps }: DrawerPrimitive.CloseProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DrawerPrimitive.Close bind:ref data-slot="drawer-close" {...restProps} />
|
||||||
33
web/src/lib/components/ui/drawer/drawer-content.svelte
Normal file
33
web/src/lib/components/ui/drawer/drawer-content.svelte
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Drawer as DrawerPrimitive } from "vaul-svelte";
|
||||||
|
import DrawerPortal from "./drawer-portal.svelte";
|
||||||
|
import DrawerOverlay from "./drawer-overlay.svelte";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
import type { ComponentProps } from "svelte";
|
||||||
|
import type { WithoutChildrenOrChild } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
portalProps,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: DrawerPrimitive.ContentProps & {
|
||||||
|
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof DrawerPortal>>;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DrawerPortal {...portalProps}>
|
||||||
|
<DrawerOverlay />
|
||||||
|
<DrawerPrimitive.Content
|
||||||
|
bind:ref
|
||||||
|
data-slot="drawer-content"
|
||||||
|
class={cn("before:bg-popover before:border-border relative flex h-auto flex-col bg-transparent p-4 text-sm before:absolute before:inset-2 before:-z-10 before:rounded-4xl before:border before:shadow-xl data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=left]:sm:max-w-sm data-[vaul-drawer-direction=right]:sm:max-w-sm group/drawer-content fixed z-50", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="bg-muted mx-auto mt-4 hidden h-1.5 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block bg-muted mx-auto hidden shrink-0 group-data-[vaul-drawer-direction=bottom]/drawer-content:block"
|
||||||
|
></div>
|
||||||
|
{@render children?.()}
|
||||||
|
</DrawerPrimitive.Content>
|
||||||
|
</DrawerPortal>
|
||||||
17
web/src/lib/components/ui/drawer/drawer-description.svelte
Normal file
17
web/src/lib/components/ui/drawer/drawer-description.svelte
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Drawer as DrawerPrimitive } from "vaul-svelte";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: DrawerPrimitive.DescriptionProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DrawerPrimitive.Description
|
||||||
|
bind:ref
|
||||||
|
data-slot="drawer-description"
|
||||||
|
class={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
20
web/src/lib/components/ui/drawer/drawer-footer.svelte
Normal file
20
web/src/lib/components/ui/drawer/drawer-footer.svelte
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="drawer-footer"
|
||||||
|
class={cn("gap-2 p-4 mt-auto flex flex-col", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
20
web/src/lib/components/ui/drawer/drawer-header.svelte
Normal file
20
web/src/lib/components/ui/drawer/drawer-header.svelte
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="drawer-header"
|
||||||
|
class={cn("gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left flex flex-col", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
12
web/src/lib/components/ui/drawer/drawer-nested.svelte
Normal file
12
web/src/lib/components/ui/drawer/drawer-nested.svelte
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Drawer as DrawerPrimitive } from "vaul-svelte";
|
||||||
|
|
||||||
|
let {
|
||||||
|
shouldScaleBackground = true,
|
||||||
|
open = $bindable(false),
|
||||||
|
activeSnapPoint = $bindable(null),
|
||||||
|
...restProps
|
||||||
|
}: DrawerPrimitive.RootProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DrawerPrimitive.NestedRoot {shouldScaleBackground} bind:open bind:activeSnapPoint {...restProps} />
|
||||||
17
web/src/lib/components/ui/drawer/drawer-overlay.svelte
Normal file
17
web/src/lib/components/ui/drawer/drawer-overlay.svelte
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Drawer as DrawerPrimitive } from "vaul-svelte";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: DrawerPrimitive.OverlayProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DrawerPrimitive.Overlay
|
||||||
|
bind:ref
|
||||||
|
data-slot="drawer-overlay"
|
||||||
|
class={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/30 supports-backdrop-filter:backdrop-blur-sm fixed inset-0 z-50", className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
7
web/src/lib/components/ui/drawer/drawer-portal.svelte
Normal file
7
web/src/lib/components/ui/drawer/drawer-portal.svelte
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Drawer as DrawerPrimitive } from "vaul-svelte";
|
||||||
|
|
||||||
|
let { ...restProps }: DrawerPrimitive.PortalProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DrawerPrimitive.Portal {...restProps} />
|
||||||
17
web/src/lib/components/ui/drawer/drawer-title.svelte
Normal file
17
web/src/lib/components/ui/drawer/drawer-title.svelte
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Drawer as DrawerPrimitive } from "vaul-svelte";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: DrawerPrimitive.TitleProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DrawerPrimitive.Title
|
||||||
|
bind:ref
|
||||||
|
data-slot="drawer-title"
|
||||||
|
class={cn("text-foreground text-base font-medium", className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
7
web/src/lib/components/ui/drawer/drawer-trigger.svelte
Normal file
7
web/src/lib/components/ui/drawer/drawer-trigger.svelte
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Drawer as DrawerPrimitive } from "vaul-svelte";
|
||||||
|
|
||||||
|
let { ref = $bindable(null), ...restProps }: DrawerPrimitive.TriggerProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DrawerPrimitive.Trigger bind:ref data-slot="drawer-trigger" {...restProps} />
|
||||||
12
web/src/lib/components/ui/drawer/drawer.svelte
Normal file
12
web/src/lib/components/ui/drawer/drawer.svelte
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Drawer as DrawerPrimitive } from "vaul-svelte";
|
||||||
|
|
||||||
|
let {
|
||||||
|
shouldScaleBackground = true,
|
||||||
|
open = $bindable(false),
|
||||||
|
activeSnapPoint = $bindable(null),
|
||||||
|
...restProps
|
||||||
|
}: DrawerPrimitive.RootProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DrawerPrimitive.Root {shouldScaleBackground} bind:open bind:activeSnapPoint {...restProps} />
|
||||||
38
web/src/lib/components/ui/drawer/index.ts
Normal file
38
web/src/lib/components/ui/drawer/index.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import Root from "./drawer.svelte";
|
||||||
|
import Content from "./drawer-content.svelte";
|
||||||
|
import Description from "./drawer-description.svelte";
|
||||||
|
import Overlay from "./drawer-overlay.svelte";
|
||||||
|
import Footer from "./drawer-footer.svelte";
|
||||||
|
import Header from "./drawer-header.svelte";
|
||||||
|
import Title from "./drawer-title.svelte";
|
||||||
|
import NestedRoot from "./drawer-nested.svelte";
|
||||||
|
import Close from "./drawer-close.svelte";
|
||||||
|
import Trigger from "./drawer-trigger.svelte";
|
||||||
|
import Portal from "./drawer-portal.svelte";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
NestedRoot,
|
||||||
|
Content,
|
||||||
|
Description,
|
||||||
|
Overlay,
|
||||||
|
Footer,
|
||||||
|
Header,
|
||||||
|
Title,
|
||||||
|
Trigger,
|
||||||
|
Portal,
|
||||||
|
Close,
|
||||||
|
|
||||||
|
//
|
||||||
|
Root as Drawer,
|
||||||
|
NestedRoot as DrawerNestedRoot,
|
||||||
|
Content as DrawerContent,
|
||||||
|
Description as DrawerDescription,
|
||||||
|
Overlay as DrawerOverlay,
|
||||||
|
Footer as DrawerFooter,
|
||||||
|
Header as DrawerHeader,
|
||||||
|
Title as DrawerTitle,
|
||||||
|
Trigger as DrawerTrigger,
|
||||||
|
Portal as DrawerPortal,
|
||||||
|
Close as DrawerClose,
|
||||||
|
};
|
||||||
20
web/src/lib/components/ui/field/field-content.svelte
Normal file
20
web/src/lib/components/ui/field/field-content.svelte
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="field-content"
|
||||||
|
class={cn("gap-1 group/field-content flex flex-1 flex-col leading-snug", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
25
web/src/lib/components/ui/field/field-description.svelte
Normal file
25
web/src/lib/components/ui/field/field-description.svelte
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLParagraphElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<p
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="field-description"
|
||||||
|
class={cn(
|
||||||
|
"text-muted-foreground text-left text-sm [[data-variant=legend]+&]:-mt-1.5 leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance",
|
||||||
|
"last:mt-0 nth-last-2:-mt-1",
|
||||||
|
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</p>
|
||||||
58
web/src/lib/components/ui/field/field-error.svelte
Normal file
58
web/src/lib/components/ui/field/field-error.svelte
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
errors,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
|
||||||
|
children?: Snippet;
|
||||||
|
errors?: { message?: string }[];
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const hasContent = $derived.by(() => {
|
||||||
|
// has slotted error
|
||||||
|
if (children) return true;
|
||||||
|
|
||||||
|
// no errors
|
||||||
|
if (!errors || errors.length === 0) return false;
|
||||||
|
|
||||||
|
// has an error but no message
|
||||||
|
if (errors.length === 1 && !errors[0]?.message) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const isMultipleErrors = $derived(errors && errors.length > 1);
|
||||||
|
const singleErrorMessage = $derived(errors && errors.length === 1 && errors[0]?.message);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if hasContent}
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
role="alert"
|
||||||
|
data-slot="field-error"
|
||||||
|
class={cn("text-destructive text-sm font-normal", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{#if children}
|
||||||
|
{@render children()}
|
||||||
|
{:else if singleErrorMessage}
|
||||||
|
{singleErrorMessage}
|
||||||
|
{:else if isMultipleErrors}
|
||||||
|
<ul class="ml-4 flex list-disc flex-col gap-1">
|
||||||
|
{#each errors ?? [] as error, index (index)}
|
||||||
|
{#if error?.message}
|
||||||
|
<li>{error.message}</li>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
23
web/src/lib/components/ui/field/field-group.svelte
Normal file
23
web/src/lib/components/ui/field/field-group.svelte
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="field-group"
|
||||||
|
class={cn(
|
||||||
|
"gap-7 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4 group/field-group @container/field-group flex w-full flex-col",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
25
web/src/lib/components/ui/field/field-label.svelte
Normal file
25
web/src/lib/components/ui/field/field-label.svelte
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Label } from "$lib/components/ui/label/index.js";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
import type { ComponentProps } from "svelte";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: ComponentProps<typeof Label> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Label
|
||||||
|
bind:ref
|
||||||
|
data-slot="field-label"
|
||||||
|
class={cn(
|
||||||
|
"has-data-checked:bg-input/30 gap-2 group-data-[disabled=true]/field:opacity-50 has-[>[data-slot=field]]:rounded-2xl has-[>[data-slot=field]]:border *:data-[slot=field]:p-4 group/field-label peer/field-label flex w-fit leading-snug",
|
||||||
|
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</Label>
|
||||||
24
web/src/lib/components/ui/field/field-legend.svelte
Normal file
24
web/src/lib/components/ui/field/field-legend.svelte
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
variant = "legend",
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLLegendElement>> & {
|
||||||
|
variant?: "legend" | "label";
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<legend
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="field-legend"
|
||||||
|
data-variant={variant}
|
||||||
|
class={cn("mb-3 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</legend>
|
||||||
35
web/src/lib/components/ui/field/field-separator.svelte
Normal file
35
web/src/lib/components/ui/field/field-separator.svelte
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Separator } from "$lib/components/ui/separator/index.js";
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
|
||||||
|
children?: Snippet;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const hasContent = $derived(!!children);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="field-separator"
|
||||||
|
data-content={hasContent}
|
||||||
|
class={cn("-my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2 relative", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
<Separator class="absolute inset-0 top-1/2" />
|
||||||
|
{#if children}
|
||||||
|
<span
|
||||||
|
class="text-muted-foreground px-2 bg-background relative mx-auto block w-fit"
|
||||||
|
data-slot="field-separator-content"
|
||||||
|
>
|
||||||
|
{@render children()}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
20
web/src/lib/components/ui/field/field-set.svelte
Normal file
20
web/src/lib/components/ui/field/field-set.svelte
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
import type { HTMLFieldsetAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLFieldsetAttributes> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<fieldset
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="field-set"
|
||||||
|
class={cn("gap-6 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3 flex flex-col", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</fieldset>
|
||||||
20
web/src/lib/components/ui/field/field-title.svelte
Normal file
20
web/src/lib/components/ui/field/field-title.svelte
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="field-label"
|
||||||
|
class={cn("gap-2 text-sm font-medium group-data-[disabled=true]/field:opacity-50 flex w-fit items-center leading-snug", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
47
web/src/lib/components/ui/field/field.svelte
Normal file
47
web/src/lib/components/ui/field/field.svelte
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<script lang="ts" module>
|
||||||
|
import { tv, type VariantProps } from "tailwind-variants";
|
||||||
|
|
||||||
|
export const fieldVariants = tv({
|
||||||
|
base: "data-[invalid=true]:text-destructive gap-3 group/field flex w-full",
|
||||||
|
variants: {
|
||||||
|
orientation: {
|
||||||
|
vertical: "cn-field-orientation-vertical flex-col [&>*]:w-full [&>.sr-only]:w-auto",
|
||||||
|
horizontal:
|
||||||
|
"cn-field-orientation-horizontal flex-row items-center has-[>[data-slot=field-content]]:items-start [&>[data-slot=field-label]]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||||
|
responsive:
|
||||||
|
"cn-field-orientation-responsive flex-col @md/field-group:flex-row @md/field-group:items-center @md/field-group:has-[>[data-slot=field-content]]:items-start [&>*]:w-full @md/field-group:[&>*]:w-auto [&>.sr-only]:w-auto @md/field-group:[&>[data-slot=field-label]]:flex-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
orientation: "vertical",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export type FieldOrientation = VariantProps<typeof fieldVariants>["orientation"];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
orientation = "vertical",
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
|
||||||
|
orientation?: FieldOrientation;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
role="group"
|
||||||
|
data-slot="field"
|
||||||
|
data-orientation={orientation}
|
||||||
|
class={cn(fieldVariants({ orientation }), className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
33
web/src/lib/components/ui/field/index.ts
Normal file
33
web/src/lib/components/ui/field/index.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import Field from "./field.svelte";
|
||||||
|
import Set from "./field-set.svelte";
|
||||||
|
import Legend from "./field-legend.svelte";
|
||||||
|
import Group from "./field-group.svelte";
|
||||||
|
import Content from "./field-content.svelte";
|
||||||
|
import Label from "./field-label.svelte";
|
||||||
|
import Title from "./field-title.svelte";
|
||||||
|
import Description from "./field-description.svelte";
|
||||||
|
import Separator from "./field-separator.svelte";
|
||||||
|
import Error from "./field-error.svelte";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Field,
|
||||||
|
Set,
|
||||||
|
Legend,
|
||||||
|
Group,
|
||||||
|
Content,
|
||||||
|
Label,
|
||||||
|
Title,
|
||||||
|
Description,
|
||||||
|
Separator,
|
||||||
|
Error,
|
||||||
|
//
|
||||||
|
Set as FieldSet,
|
||||||
|
Legend as FieldLegend,
|
||||||
|
Group as FieldGroup,
|
||||||
|
Content as FieldContent,
|
||||||
|
Label as FieldLabel,
|
||||||
|
Title as FieldTitle,
|
||||||
|
Description as FieldDescription,
|
||||||
|
Separator as FieldSeparator,
|
||||||
|
Error as FieldError,
|
||||||
|
};
|
||||||
7
web/src/lib/components/ui/input/index.ts
Normal file
7
web/src/lib/components/ui/input/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import Root from "./input.svelte";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
//
|
||||||
|
Root as Input,
|
||||||
|
};
|
||||||
48
web/src/lib/components/ui/input/input.svelte
Normal file
48
web/src/lib/components/ui/input/input.svelte
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLInputAttributes, HTMLInputTypeAttribute } from "svelte/elements";
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
|
type InputType = Exclude<HTMLInputTypeAttribute, "file">;
|
||||||
|
|
||||||
|
type Props = WithElementRef<
|
||||||
|
Omit<HTMLInputAttributes, "type"> &
|
||||||
|
({ type: "file"; files?: FileList } | { type?: InputType; files?: undefined })
|
||||||
|
>;
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
value = $bindable(),
|
||||||
|
type,
|
||||||
|
files = $bindable(),
|
||||||
|
class: className,
|
||||||
|
"data-slot": dataSlot = "input",
|
||||||
|
...restProps
|
||||||
|
}: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if type === "file"}
|
||||||
|
<input
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot={dataSlot}
|
||||||
|
class={cn(
|
||||||
|
"bg-input/50 focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 h-9 rounded-3xl border border-transparent px-3 py-1 text-base transition-[color,box-shadow,background-color] file:h-7 file:text-sm file:font-medium focus-visible:ring-3 aria-invalid:ring-3 md:text-sm file:text-foreground placeholder:text-muted-foreground w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
type="file"
|
||||||
|
bind:files
|
||||||
|
bind:value
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<input
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot={dataSlot}
|
||||||
|
class={cn(
|
||||||
|
"bg-input/50 focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 h-9 rounded-3xl border border-transparent px-3 py-1 text-base transition-[color,box-shadow,background-color] file:h-7 file:text-sm file:font-medium focus-visible:ring-3 aria-invalid:ring-3 md:text-sm file:text-foreground placeholder:text-muted-foreground w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{type}
|
||||||
|
bind:value
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
7
web/src/lib/components/ui/label/index.ts
Normal file
7
web/src/lib/components/ui/label/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import Root from "./label.svelte";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
//
|
||||||
|
Root as Label,
|
||||||
|
};
|
||||||
20
web/src/lib/components/ui/label/label.svelte
Normal file
20
web/src/lib/components/ui/label/label.svelte
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Label as LabelPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: LabelPrimitive.RootProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
bind:ref
|
||||||
|
data-slot="label"
|
||||||
|
class={cn(
|
||||||
|
"gap-2 text-sm leading-none font-medium group-data-[disabled=true]:opacity-50 peer-disabled:opacity-50 flex items-center select-none group-data-[disabled=true]:pointer-events-none peer-disabled:cursor-not-allowed",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
7
web/src/lib/components/ui/separator/index.ts
Normal file
7
web/src/lib/components/ui/separator/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import Root from "./separator.svelte";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
//
|
||||||
|
Root as Separator,
|
||||||
|
};
|
||||||
23
web/src/lib/components/ui/separator/separator.svelte
Normal file
23
web/src/lib/components/ui/separator/separator.svelte
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Separator as SeparatorPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
"data-slot": dataSlot = "separator",
|
||||||
|
...restProps
|
||||||
|
}: SeparatorPrimitive.RootProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
bind:ref
|
||||||
|
data-slot={dataSlot}
|
||||||
|
class={cn(
|
||||||
|
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px",
|
||||||
|
// this is different in shadcn/ui but self-stretch breaks things for us
|
||||||
|
"data-[orientation=vertical]:h-full",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
1
web/src/lib/components/ui/sonner/index.ts
Normal file
1
web/src/lib/components/ui/sonner/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default as Toaster } from "./sonner.svelte";
|
||||||
34
web/src/lib/components/ui/sonner/sonner.svelte
Normal file
34
web/src/lib/components/ui/sonner/sonner.svelte
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Toaster as Sonner, type ToasterProps as SonnerProps } from "svelte-sonner";
|
||||||
|
import { mode } from "mode-watcher";
|
||||||
|
import RiLoaderLine from 'remixicon-svelte/icons/loader-line';
|
||||||
|
import RiCheckboxCircleLine from 'remixicon-svelte/icons/checkbox-circle-line';
|
||||||
|
import RiErrorWarningLine from 'remixicon-svelte/icons/error-warning-line';
|
||||||
|
import RiInformationLine from 'remixicon-svelte/icons/information-line';
|
||||||
|
import RiCloseCircleLine from 'remixicon-svelte/icons/close-circle-line';
|
||||||
|
|
||||||
|
let { ...restProps }: SonnerProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Sonner
|
||||||
|
theme={mode.current}
|
||||||
|
class="toaster group"
|
||||||
|
style="--normal-bg: var(--color-popover); --normal-text: var(--color-popover-foreground); --normal-border: var(--color-border);"
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{#snippet loadingIcon()}
|
||||||
|
<RiLoaderLine class="size-4 animate-spin" />
|
||||||
|
{/snippet}
|
||||||
|
{#snippet successIcon()}
|
||||||
|
<RiCheckboxCircleLine class="size-4" />
|
||||||
|
{/snippet}
|
||||||
|
{#snippet errorIcon()}
|
||||||
|
<RiErrorWarningLine class="size-4" />
|
||||||
|
{/snippet}
|
||||||
|
{#snippet infoIcon()}
|
||||||
|
<RiInformationLine class="size-4" />
|
||||||
|
{/snippet}
|
||||||
|
{#snippet warningIcon()}
|
||||||
|
<RiCloseCircleLine class="size-4" />
|
||||||
|
{/snippet}
|
||||||
|
</Sonner>
|
||||||
1
web/src/lib/components/ui/spinner/index.ts
Normal file
1
web/src/lib/components/ui/spinner/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default as Spinner } from "./spinner.svelte";
|
||||||
18
web/src/lib/components/ui/spinner/spinner.svelte
Normal file
18
web/src/lib/components/ui/spinner/spinner.svelte
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
import RiLoaderLine from 'remixicon-svelte/icons/loader-line';
|
||||||
|
import type { SVGAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
class: className,
|
||||||
|
role = "status",
|
||||||
|
// we add name, color, and stroke for compatibility with different icon libraries props
|
||||||
|
name,
|
||||||
|
color,
|
||||||
|
stroke,
|
||||||
|
"aria-label": ariaLabel = "Loading",
|
||||||
|
...restProps
|
||||||
|
}: SVGAttributes<SVGSVGElement> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<RiLoaderLine {role} name={name === null ? undefined : name} color={color === null ? undefined : color} stroke={stroke === null ? undefined : stroke} aria-label={ariaLabel} class={cn("size-4 animate-spin", className)} {...restProps} />
|
||||||
20
web/src/lib/index.ts
Normal file
20
web/src/lib/index.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { dev } from "$app/environment";
|
||||||
|
import * as env from "$env/static/public";
|
||||||
|
import PocketBase, { LocalAuthStore } from "pocketbase";
|
||||||
|
import type { TypedPocketBase } from "./pocketbase-types";
|
||||||
|
export type { ClientResponseError } from "pocketbase";
|
||||||
|
|
||||||
|
const forcePublicAuthStore =
|
||||||
|
"PUBLIC_PROD_AUTH_STORE" in env &&
|
||||||
|
typeof env.PUBLIC_PROD_AUTH_STORE === "string" &&
|
||||||
|
["true", "t", "yes", "y", "1"].includes(env.PUBLIC_PROD_AUTH_STORE.toLowerCase());
|
||||||
|
export const pb = new PocketBase(
|
||||||
|
undefined,
|
||||||
|
dev && !forcePublicAuthStore
|
||||||
|
? new LocalAuthStore("__pb_superuser_auth__") // When in development, and not forcing the prod auth store, we just hijack the PocketBase Admin UI session
|
||||||
|
: new LocalAuthStore("cctv_auth")
|
||||||
|
) as TypedPocketBase;
|
||||||
|
|
||||||
|
export function tw(s: TemplateStringsArray, ...args: unknown[]) {
|
||||||
|
return s.reduce((acc, str, idx) => acc + str + (args[idx] || ""), "");
|
||||||
|
}
|
||||||
226
web/src/lib/pocketbase-types.ts
Normal file
226
web/src/lib/pocketbase-types.ts
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
/**
|
||||||
|
* This file was @generated using pocketbase-typegen
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type PocketBase from 'pocketbase'
|
||||||
|
import type { RecordService } from 'pocketbase'
|
||||||
|
|
||||||
|
export enum Collections {
|
||||||
|
Authorigins = "_authOrigins",
|
||||||
|
Externalauths = "_externalAuths",
|
||||||
|
Mfas = "_mfas",
|
||||||
|
Otps = "_otps",
|
||||||
|
Superusers = "_superusers",
|
||||||
|
Cameras = "cameras",
|
||||||
|
Streams = "streams",
|
||||||
|
Users = "users",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alias types for improved usability
|
||||||
|
export type IsoDateString = string
|
||||||
|
export type IsoAutoDateString = string & { readonly autodate: unique symbol }
|
||||||
|
export type RecordIdString = string
|
||||||
|
export type FileNameString = string & { readonly filename: unique symbol }
|
||||||
|
export type HTMLString = string
|
||||||
|
|
||||||
|
type ExpandType<T> = unknown extends T
|
||||||
|
? T extends unknown
|
||||||
|
? { expand?: unknown }
|
||||||
|
: { expand: T }
|
||||||
|
: { expand: T }
|
||||||
|
|
||||||
|
// System fields
|
||||||
|
export type BaseSystemFields<T = unknown> = {
|
||||||
|
id: RecordIdString
|
||||||
|
collectionId: string
|
||||||
|
collectionName: Collections
|
||||||
|
} & ExpandType<T>
|
||||||
|
|
||||||
|
export type AuthSystemFields<T = unknown> = {
|
||||||
|
email: string
|
||||||
|
emailVisibility: boolean
|
||||||
|
username: string
|
||||||
|
verified: boolean
|
||||||
|
} & BaseSystemFields<T>
|
||||||
|
|
||||||
|
// Record types for each collection
|
||||||
|
|
||||||
|
export type AuthoriginsRecord = {
|
||||||
|
collectionRef: string
|
||||||
|
created: IsoAutoDateString
|
||||||
|
fingerprint: string
|
||||||
|
id: string
|
||||||
|
recordRef: string
|
||||||
|
updated: IsoAutoDateString
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ExternalauthsRecord = {
|
||||||
|
collectionRef: string
|
||||||
|
created: IsoAutoDateString
|
||||||
|
id: string
|
||||||
|
provider: string
|
||||||
|
providerId: string
|
||||||
|
recordRef: string
|
||||||
|
updated: IsoAutoDateString
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MfasRecord = {
|
||||||
|
collectionRef: string
|
||||||
|
created: IsoAutoDateString
|
||||||
|
id: string
|
||||||
|
method: string
|
||||||
|
recordRef: string
|
||||||
|
updated: IsoAutoDateString
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OtpsRecord = {
|
||||||
|
collectionRef: string
|
||||||
|
created: IsoAutoDateString
|
||||||
|
id: string
|
||||||
|
password: string
|
||||||
|
recordRef: string
|
||||||
|
sentTo?: string
|
||||||
|
updated: IsoAutoDateString
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SuperusersRecord = {
|
||||||
|
created: IsoAutoDateString
|
||||||
|
email: string
|
||||||
|
emailVisibility?: boolean
|
||||||
|
id: string
|
||||||
|
password: string
|
||||||
|
tokenKey: string
|
||||||
|
updated: IsoAutoDateString
|
||||||
|
verified?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CamerasRecord = {
|
||||||
|
created: IsoAutoDateString
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
onvif_host?: string
|
||||||
|
password: string
|
||||||
|
record_stream?: RecordIdString
|
||||||
|
updated?: IsoAutoDateString
|
||||||
|
username: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StreamsRecord = {
|
||||||
|
camera: RecordIdString
|
||||||
|
fps: number
|
||||||
|
height: number
|
||||||
|
id: string
|
||||||
|
url: string
|
||||||
|
width: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UsersRecord = {
|
||||||
|
created: IsoAutoDateString
|
||||||
|
email: string
|
||||||
|
emailVisibility?: boolean
|
||||||
|
id: string
|
||||||
|
password: string
|
||||||
|
tokenKey: string
|
||||||
|
updated?: IsoAutoDateString
|
||||||
|
verified?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response types include system fields and match responses from the PocketBase API
|
||||||
|
export type AuthoriginsResponse<Texpand = unknown> = Required<AuthoriginsRecord> & BaseSystemFields<Texpand>
|
||||||
|
export type ExternalauthsResponse<Texpand = unknown> = Required<ExternalauthsRecord> & BaseSystemFields<Texpand>
|
||||||
|
export type MfasResponse<Texpand = unknown> = Required<MfasRecord> & BaseSystemFields<Texpand>
|
||||||
|
export type OtpsResponse<Texpand = unknown> = Required<OtpsRecord> & BaseSystemFields<Texpand>
|
||||||
|
export type SuperusersResponse<Texpand = unknown> = Required<SuperusersRecord> & AuthSystemFields<Texpand>
|
||||||
|
export type CamerasResponse<Texpand = unknown> = Required<CamerasRecord> & BaseSystemFields<Texpand>
|
||||||
|
export type StreamsResponse<Texpand = unknown> = Required<StreamsRecord> & BaseSystemFields<Texpand>
|
||||||
|
export type UsersResponse<Texpand = unknown> = Required<UsersRecord> & AuthSystemFields<Texpand>
|
||||||
|
|
||||||
|
// Types containing all Records and Responses, useful for creating typing helper functions
|
||||||
|
|
||||||
|
export type CollectionRecords = {
|
||||||
|
_authOrigins: AuthoriginsRecord
|
||||||
|
_externalAuths: ExternalauthsRecord
|
||||||
|
_mfas: MfasRecord
|
||||||
|
_otps: OtpsRecord
|
||||||
|
_superusers: SuperusersRecord
|
||||||
|
cameras: CamerasRecord
|
||||||
|
streams: StreamsRecord
|
||||||
|
users: UsersRecord
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CollectionResponses = {
|
||||||
|
_authOrigins: AuthoriginsResponse
|
||||||
|
_externalAuths: ExternalauthsResponse
|
||||||
|
_mfas: MfasResponse
|
||||||
|
_otps: OtpsResponse
|
||||||
|
_superusers: SuperusersResponse
|
||||||
|
cameras: CamerasResponse
|
||||||
|
streams: StreamsResponse
|
||||||
|
users: UsersResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utility types for create/update operations
|
||||||
|
|
||||||
|
type ProcessCreateAndUpdateFields<T> = Omit<{
|
||||||
|
// Omit AutoDate fields
|
||||||
|
[K in keyof T as Extract<T[K], IsoAutoDateString> extends never ? K : never]:
|
||||||
|
// Convert FileNameString to File
|
||||||
|
T[K] extends infer U ?
|
||||||
|
U extends (FileNameString | FileNameString[]) ?
|
||||||
|
U extends any[] ? File[] : File
|
||||||
|
: U
|
||||||
|
: never
|
||||||
|
}, 'id'>
|
||||||
|
|
||||||
|
// Create type for Auth collections
|
||||||
|
export type CreateAuth<T> = {
|
||||||
|
id?: RecordIdString
|
||||||
|
email: string
|
||||||
|
emailVisibility?: boolean
|
||||||
|
password: string
|
||||||
|
passwordConfirm: string
|
||||||
|
verified?: boolean
|
||||||
|
} & ProcessCreateAndUpdateFields<T>
|
||||||
|
|
||||||
|
// Create type for Base collections
|
||||||
|
export type CreateBase<T> = {
|
||||||
|
id?: RecordIdString
|
||||||
|
} & ProcessCreateAndUpdateFields<T>
|
||||||
|
|
||||||
|
// Update type for Auth collections
|
||||||
|
export type UpdateAuth<T> = Partial<
|
||||||
|
Omit<ProcessCreateAndUpdateFields<T>, keyof AuthSystemFields>
|
||||||
|
> & {
|
||||||
|
email?: string
|
||||||
|
emailVisibility?: boolean
|
||||||
|
oldPassword?: string
|
||||||
|
password?: string
|
||||||
|
passwordConfirm?: string
|
||||||
|
verified?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update type for Base collections
|
||||||
|
export type UpdateBase<T> = Partial<
|
||||||
|
Omit<ProcessCreateAndUpdateFields<T>, keyof BaseSystemFields>
|
||||||
|
>
|
||||||
|
|
||||||
|
// Get the correct create type for any collection
|
||||||
|
export type Create<T extends keyof CollectionResponses> =
|
||||||
|
CollectionResponses[T] extends AuthSystemFields
|
||||||
|
? CreateAuth<CollectionRecords[T]>
|
||||||
|
: CreateBase<CollectionRecords[T]>
|
||||||
|
|
||||||
|
// Get the correct update type for any collection
|
||||||
|
export type Update<T extends keyof CollectionResponses> =
|
||||||
|
CollectionResponses[T] extends AuthSystemFields
|
||||||
|
? UpdateAuth<CollectionRecords[T]>
|
||||||
|
: UpdateBase<CollectionRecords[T]>
|
||||||
|
|
||||||
|
// Type for usage with type asserted PocketBase instance
|
||||||
|
// https://github.com/pocketbase/js-sdk#specify-typescript-definitions
|
||||||
|
|
||||||
|
export type TypedPocketBase = {
|
||||||
|
collection<T extends keyof CollectionResponses>(
|
||||||
|
idOrName: T
|
||||||
|
): RecordService<CollectionResponses[T]>
|
||||||
|
} & PocketBase
|
||||||
13
web/src/lib/utils.ts
Normal file
13
web/src/lib/utils.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
export type WithoutChild<T> = T extends { child?: any } ? Omit<T, "child"> : T;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
export type WithoutChildren<T> = T extends { children?: any } ? Omit<T, "children"> : T;
|
||||||
|
export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>;
|
||||||
|
export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & { ref?: U | null };
|
||||||
22
web/src/routes/(authenticated)/(home)/+layout.svelte
Normal file
22
web/src/routes/(authenticated)/(home)/+layout.svelte
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { resolve } from "$app/paths";
|
||||||
|
import Button from "$lib/components/ui/button/button.svelte";
|
||||||
|
import VideoAdd from "remixicon-svelte/icons/video-add-fill";
|
||||||
|
|
||||||
|
const { children } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="fixed top-0 left-0 flex h-12 w-screen items-center p-2">
|
||||||
|
<h2 class="flex-1 text-2xl">CCTV</h2>
|
||||||
|
<Button href={resolve("/new")} variant="outline" size="icon" aria-label="New camera">
|
||||||
|
<VideoAdd />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
{#each Array(1000) as _, idx (idx)}
|
||||||
|
<p>Hey!</p>
|
||||||
|
{/each}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{@render children()}
|
||||||
0
web/src/routes/(authenticated)/(home)/+page.svelte
Normal file
0
web/src/routes/(authenticated)/(home)/+page.svelte
Normal file
137
web/src/routes/(authenticated)/(home)/new/+page.svelte
Normal file
137
web/src/routes/(authenticated)/(home)/new/+page.svelte
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import { resolve } from "$app/paths";
|
||||||
|
import { pb, type ClientResponseError } from "$lib";
|
||||||
|
import { Button } from "$lib/components/ui/button";
|
||||||
|
import * as ButtonGroup from "$lib/components/ui/button-group";
|
||||||
|
import * as Drawer from "$lib/components/ui/drawer";
|
||||||
|
import * as Field from "$lib/components/ui/field";
|
||||||
|
import { Input } from "$lib/components/ui/input";
|
||||||
|
import { Spinner } from "$lib/components/ui/spinner";
|
||||||
|
import CloseCircleLine from "remixicon-svelte/icons/close-circle-line";
|
||||||
|
import { toast } from "svelte-sonner";
|
||||||
|
|
||||||
|
let name = $state("");
|
||||||
|
let host = $state("");
|
||||||
|
let port = $state("");
|
||||||
|
let username = $state("");
|
||||||
|
let password = $state("");
|
||||||
|
let adding = $state(false);
|
||||||
|
|
||||||
|
let portEl = $state<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
function onHostInput(e: Event) {
|
||||||
|
const el = e.currentTarget as HTMLInputElement;
|
||||||
|
const value = el.value;
|
||||||
|
const separatorIndex = value.lastIndexOf(":");
|
||||||
|
|
||||||
|
if (separatorIndex === -1) return;
|
||||||
|
|
||||||
|
host = value.slice(0, separatorIndex);
|
||||||
|
const rawPort = value.slice(separatorIndex + 1);
|
||||||
|
if (rawPort.length > 0) port = rawPort.replace(/\D/g, "").slice(0, 5);
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
portEl?.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tryAddCamera() {
|
||||||
|
adding = true;
|
||||||
|
try {
|
||||||
|
const camera = await pb.collection("cameras").create({
|
||||||
|
name,
|
||||||
|
onvif_host: `${host}:${port}`,
|
||||||
|
username,
|
||||||
|
password
|
||||||
|
});
|
||||||
|
await goto(resolve(`/cam/${camera.id}`));
|
||||||
|
} catch (_err) {
|
||||||
|
const err = _err as ClientResponseError;
|
||||||
|
console.error("Add camera error:", err);
|
||||||
|
toast.error(err.message ?? "An error occurred while adding the camera.", {
|
||||||
|
icon: CloseCircleLine
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
adding = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Drawer.Root
|
||||||
|
bind:open={
|
||||||
|
() => true,
|
||||||
|
(v) => {
|
||||||
|
if (!v) goto(resolve("/"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Drawer.Content>
|
||||||
|
<div class="mx-auto w-full max-w-sm overflow-y-auto p-1">
|
||||||
|
<Drawer.Header>
|
||||||
|
<Drawer.Title class="text-xl">New Camera</Drawer.Title>
|
||||||
|
</Drawer.Header>
|
||||||
|
<form
|
||||||
|
onsubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
tryAddCamera();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Field.Set>
|
||||||
|
<Field.Group>
|
||||||
|
<Field.Field>
|
||||||
|
<Field.Label for="name">Camera Name</Field.Label>
|
||||||
|
<Input id="name" placeholder="My Camera" bind:value={name} disabled={adding} />
|
||||||
|
</Field.Field>
|
||||||
|
<Field.Field>
|
||||||
|
<Field.Label for="host">Camera URL</Field.Label>
|
||||||
|
<ButtonGroup.Root>
|
||||||
|
<ButtonGroup.Text>onvif://</ButtonGroup.Text>
|
||||||
|
<Input
|
||||||
|
id="host"
|
||||||
|
bind:value={host}
|
||||||
|
oninput={onHostInput}
|
||||||
|
placeholder="IP Address"
|
||||||
|
disabled={adding}
|
||||||
|
/>
|
||||||
|
<ButtonGroup.Separator />
|
||||||
|
<ButtonGroup.Text>:</ButtonGroup.Text>
|
||||||
|
<Input
|
||||||
|
id="port"
|
||||||
|
bind:ref={portEl}
|
||||||
|
bind:value={port}
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={65535}
|
||||||
|
class="min-w-22! flex-0!"
|
||||||
|
placeholder="Port"
|
||||||
|
disabled={adding}
|
||||||
|
/>
|
||||||
|
</ButtonGroup.Root>
|
||||||
|
</Field.Field>
|
||||||
|
<Field.Field>
|
||||||
|
<Field.Label for="username">ONVIF Username</Field.Label>
|
||||||
|
<Input id="username" placeholder="Username" bind:value={username} disabled={adding} />
|
||||||
|
</Field.Field>
|
||||||
|
<Field.Field>
|
||||||
|
<Field.Label for="password">ONVIF Password</Field.Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
placeholder="Password"
|
||||||
|
bind:value={password}
|
||||||
|
disabled={adding}
|
||||||
|
/>
|
||||||
|
</Field.Field>
|
||||||
|
<Button type="submit" disabled={adding}>
|
||||||
|
{#if adding}
|
||||||
|
<Spinner />
|
||||||
|
{/if}
|
||||||
|
Add Camera
|
||||||
|
</Button>
|
||||||
|
</Field.Group>
|
||||||
|
</Field.Set>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</Drawer.Content>
|
||||||
|
</Drawer.Root>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user