This commit is contained in:
2026-04-22 23:35:59 +01:00
parent df6c33bc3a
commit bee7869af4
116 changed files with 13552 additions and 0 deletions

4
server/.env.default Normal file
View 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
View File

@@ -0,0 +1,2 @@
pb_data/
recordings/

39
server/.golangci.yml Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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()
}

View 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)
}
}

View 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
}

View 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
View 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
View 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
View 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
View 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()
}

View 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
})
}

View 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)
})
}

View 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)
})
}

View 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
View 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
View 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)
}