wip
This commit is contained in:
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)
|
||||
}
|
||||
Reference in New Issue
Block a user