Compare commits

2 Commits

Author SHA1 Message Date
285533c13e demo live stream 2026-04-23 23:49:10 +01:00
bee7869af4 wip 2026-04-22 23:35:59 +01:00
119 changed files with 14373 additions and 0 deletions

View File

@@ -0,0 +1,24 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
// README at: https://github.com/devcontainers/templates/tree/main/src/debian
{
"features": {
"ghcr.io/devcontainers/features/go:1": {
"version": "1.26",
"golangciLintVersion": "2"
},
"ghcr.io/devcontainers/features/node:1": {
"version": "lts"
}
},
"name": "Debian",
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
"image": "mcr.microsoft.com/devcontainers/base:trixie"
// Features to add to the dev container. More info: https://containers.dev/features.
// "features": {},
// Use 'forwardPorts' to make a list of ports inside the container available locally.
// "forwardPorts": [],
// Configure tool-specific properties.
// "customizations": {},
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
// "remoteUser": "root"
}

5
.editorconfig Normal file
View File

@@ -0,0 +1,5 @@
root = true
[*.json]
indent_style = tab
indent_size = 4

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
.env*
!.env.default

41
cctv.code-workspace Normal file
View File

@@ -0,0 +1,41 @@
{
"folders": [
{
"name": "/",
"path": "."
},
{
"name": "server",
"path": "server"
},
{
"name": "web",
"path": "web"
}
],
"extensions": {
"recommendations": [
"golang.go",
"esbenp.prettier-vscode",
"editorconfig.editorconfig",
"svelte.svelte-vscode",
"dbaeumer.vscode-eslint",
"bradlc.vscode-tailwindcss"
]
},
"settings": {
"files.associations": {
"*.css": "tailwindcss"
},
"tailwindCSS.classAttributes": [
"class",
"className",
"ngClass",
"class:list",
"classes",
".*Class",
".*Classes"
],
"tailwindCSS.classFunctions": ["tw"]
}
}

4
server/.env.default Normal file
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"
]
}

114
server/api.go Normal file
View File

@@ -0,0 +1,114 @@
package main
import (
"context"
"errors"
"io"
"net/http"
"github.com/gorilla/websocket"
"github.com/pocketbase/pocketbase/core"
)
var liveWSUpgrader = websocket.Upgrader{
CheckOrigin: func(_ *http.Request) bool {
return true
},
}
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 {
return e.BadRequestError("Use /api/cctv/live/ws/{streamId} for live streaming", nil)
})
group.GET("/live/ws/{streamId}", func(e *core.RequestEvent) error {
streamId := e.Request.PathValue("streamId")
if streamId == "" {
return e.BadRequestError("Missing stream ID", nil)
}
conn, err := liveWSUpgrader.Upgrade(e.Response, e.Request, nil)
if err != nil {
return e.InternalServerError("Failed to upgrade websocket connection", err)
}
defer conn.Close()
ctx, cancel := context.WithCancel(e.Request.Context())
defer cancel()
stream, err := ingestService.SubscribeLive(ctx, streamId)
if err != nil {
writeLiveWSError(conn, err.Error())
return nil
}
clientDone := make(chan struct{})
go func() {
defer close(clientDone)
for {
if _, _, err := conn.ReadMessage(); err != nil {
return
}
}
}()
for {
select {
case <-ctx.Done():
return nil
case <-clientDone:
return nil
case chunk, ok := <-stream:
if !ok {
writeLiveWSError(conn, "live stream ended")
return nil
}
if chunk == nil {
continue
}
payload, err := io.ReadAll(chunk)
if err != nil {
writeLiveWSError(conn, "failed to read stream chunk")
return nil
}
if err := conn.WriteMessage(websocket.BinaryMessage, payload); err != nil {
if errors.Is(err, websocket.ErrCloseSent) {
return nil
}
return nil
}
}
}
})
}
func writeLiveWSError(conn *websocket.Conn, msg string) {
payload := make([]byte, len(msg)+1)
payload[0] = 0x03
copy(payload[1:], []byte(msg))
_ = conn.WriteMessage(websocket.BinaryMessage, payload)
}

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

53
server/go.mod Normal file
View File

@@ -0,0 +1,53 @@
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/gorilla/websocket v1.5.3
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
)

156
server/go.sum Normal file
View File

@@ -0,0 +1,156 @@
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/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
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,372 @@
package ingest
import (
"bytes"
"context"
"fmt"
"time"
"github.com/go-gst/go-gst/gst"
"github.com/go-gst/go-gst/gst/app"
)
const (
liveFrameTypeInit = 0x01
liveFrameTypeMedia = 0x02
liveFrameTypeError = 0x03
)
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)); 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
assembler := newFMP4Assembler()
var lastMediaEmit time.Time
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")
initSeg = nil
assembler = newFMP4Assembler()
lastMediaEmit = time.Time{}
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:
chunks, err := assembler.Push(data)
if err != nil {
p.log.Warn("Failed to assemble live fMP4 chunks", "error", err)
continue
}
numSamples += 1
for _, chunk := range chunks {
framed := marshalLiveFrame(chunk.FrameType, chunk.Payload)
switch chunk.FrameType {
case liveFrameTypeInit:
initSeg = framed
p.log.Debug("Emitted init segment", "size", len(chunk.Payload))
case liveFrameTypeMedia:
now := time.Now()
if !lastMediaEmit.IsZero() {
p.log.Debug("Emitted media segment", "size", len(chunk.Payload), "gap", now.Sub(lastMediaEmit))
} else {
p.log.Debug("Emitted media segment", "size", len(chunk.Payload))
}
lastMediaEmit = now
}
subscribers.Broadcast(framed)
}
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 marshalLiveFrame(frameType byte, payload []byte) []byte {
framed := make([]byte, len(payload)+1)
framed[0] = frameType
copy(framed[1:], payload)
return framed
}
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
}

View File

@@ -0,0 +1,139 @@
package ingest
import (
"encoding/binary"
"fmt"
)
type liveChunk struct {
FrameType byte
Payload []byte
}
type fmp4Assembler struct {
pending []byte
initReady bool
initBuf []byte
mediaPrefix []byte
inFragment bool
fragmentBuf []byte
}
func newFMP4Assembler() *fmp4Assembler {
return &fmp4Assembler{}
}
func (a *fmp4Assembler) Push(data []byte) ([]liveChunk, error) {
if len(data) == 0 {
return nil, nil
}
a.pending = append(a.pending, data...)
result := make([]liveChunk, 0, 2)
for {
box, boxType, ok, err := a.nextBox()
if err != nil {
return nil, err
}
if !ok {
break
}
chunks, err := a.consumeBox(boxType, box)
if err != nil {
return nil, err
}
result = append(result, chunks...)
}
return result, nil
}
func (a *fmp4Assembler) nextBox() ([]byte, string, bool, error) {
if len(a.pending) < 8 {
return nil, "", false, nil
}
headerLen := 8
size := uint64(binary.BigEndian.Uint32(a.pending[:4]))
if size == 1 {
if len(a.pending) < 16 {
return nil, "", false, nil
}
headerLen = 16
size = binary.BigEndian.Uint64(a.pending[8:16])
}
if size == 0 {
// 0 means box runs to stream end; wait for more data.
return nil, "", false, nil
}
if size < uint64(headerLen) {
return nil, "", false, fmt.Errorf("invalid MP4 box size %d", size)
}
if size > uint64(len(a.pending)) {
return nil, "", false, nil
}
boxLen := int(size)
boxType := string(a.pending[4:8])
box := make([]byte, boxLen)
copy(box, a.pending[:boxLen])
a.pending = a.pending[boxLen:]
return box, boxType, true, nil
}
func (a *fmp4Assembler) consumeBox(boxType string, box []byte) ([]liveChunk, error) {
if !a.initReady {
a.initBuf = append(a.initBuf, box...)
if boxType != "moov" {
return nil, nil
}
a.initReady = true
initPayload := cloneBytes(a.initBuf)
a.initBuf = nil
return []liveChunk{{FrameType: liveFrameTypeInit, Payload: initPayload}}, nil
}
switch boxType {
case "styp", "sidx", "prft":
if a.inFragment {
a.fragmentBuf = append(a.fragmentBuf, box...)
} else {
a.mediaPrefix = append(a.mediaPrefix, box...)
}
return nil, nil
case "moof":
a.fragmentBuf = a.fragmentBuf[:0]
if len(a.mediaPrefix) > 0 {
a.fragmentBuf = append(a.fragmentBuf, a.mediaPrefix...)
a.mediaPrefix = a.mediaPrefix[:0]
}
a.fragmentBuf = append(a.fragmentBuf, box...)
a.inFragment = true
return nil, nil
case "mdat":
if !a.inFragment {
return nil, nil
}
a.fragmentBuf = append(a.fragmentBuf, box...)
mediaPayload := cloneBytes(a.fragmentBuf)
a.fragmentBuf = a.fragmentBuf[:0]
a.inFragment = false
return []liveChunk{{FrameType: liveFrameTypeMedia, Payload: mediaPayload}}, nil
default:
if a.inFragment {
a.fragmentBuf = append(a.fragmentBuf, box...)
}
return nil, nil
}
}
func cloneBytes(data []byte) []byte {
if len(data) == 0 {
return nil
}
out := make([]byte, len(data))
copy(out, data)
return out
}

179
server/ingest/ingest.go Normal file
View File

@@ -0,0 +1,179 @@
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)
}
out := make(chan *bytes.Reader, 16)
go func() {
defer close(out)
defer active.pipeline.LiveUnsubscribe(id)
for {
select {
case <-ctx.Done():
return
case buf, ok := <-stream:
if !ok {
return
}
select {
case <-ctx.Done():
return
case out <- buf:
}
}
}
}()
return out, nil
}

333
server/ingest/pipeline.go Normal file
View File

@@ -0,0 +1,333 @@
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.addStaticChainProps(
pad,
p.vTee.GetStaticPad("sink"),
elementFactory{name: "rtph264depay"},
elementFactory{name: "h264parse", props: map[string]any{"config-interval": int(-1)}},
)
case "H265":
chain, err = p.addStaticChainProps(
pad,
p.vTee.GetStaticPad("sink"),
elementFactory{name: "rtph265depay"},
elementFactory{name: "h265parse", props: map[string]any{"config-interval": int(-1)}},
)
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)
}

1
web/.env.example Normal file
View File

@@ -0,0 +1 @@
PUBLIC_PROD_AUTH_STORE=false

1
web/.eslintignore Normal file
View File

@@ -0,0 +1 @@
src/lib/pocketbase-types.ts

28
web/.gitignore vendored Normal file
View File

@@ -0,0 +1,28 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
# Playwright
test-results
*storybook.log
storybook-static

1
web/.npmrc Normal file
View File

@@ -0,0 +1 @@
engine-strict=true

10
web/.prettierignore Normal file
View File

@@ -0,0 +1,10 @@
# Package Managers
package-lock.json
pnpm-lock.yaml
yarn.lock
bun.lock
bun.lockb
# Miscellaneous
/static/
src/lib/pocketbase-types.ts

16
web/.prettierrc Normal file
View File

@@ -0,0 +1,16 @@
{
"useTabs": true,
"singleQuote": false,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
"overrides": [
{
"files": "*.svelte",
"options": {
"parser": "svelte"
}
}
],
"tailwindStylesheet": "./src/routes/layout.css"
}

14
web/.storybook/main.ts Normal file
View File

@@ -0,0 +1,14 @@
import type { StorybookConfig } from "@storybook/sveltekit";
const config: StorybookConfig = {
stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|ts|svelte)"],
addons: [
"@storybook/addon-svelte-csf",
"@chromatic-com/storybook",
"@storybook/addon-vitest",
"@storybook/addon-a11y",
"@storybook/addon-docs"
],
framework: "@storybook/sveltekit"
};
export default config;

21
web/.storybook/preview.ts Normal file
View File

@@ -0,0 +1,21 @@
import type { Preview } from "@storybook/sveltekit";
const preview: Preview = {
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i
}
},
a11y: {
// 'todo' - show a11y violations in the test UI only
// 'error' - fail CI on a11y violations
// 'off' - skip a11y checks entirely
test: "todo"
}
}
};
export default preview;

7
web/.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,7 @@
{
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.organizeImports": "always"
}
}

20
web/components.json Normal file
View File

@@ -0,0 +1,20 @@
{
"$schema": "https://shadcn-svelte.com/schema.json",
"tailwind": {
"css": "src/routes/layout.css",
"baseColor": "mist"
},
"aliases": {
"components": "$lib/components",
"utils": "$lib/utils",
"ui": "$lib/components/ui",
"hooks": "$lib/hooks",
"lib": "$lib"
},
"typescript": true,
"registry": "https://shadcn-svelte.com/registry",
"style": "luma",
"iconLibrary": "remixicon",
"menuColor": "default",
"menuAccent": "bold"
}

42
web/eslint.config.js Normal file
View File

@@ -0,0 +1,42 @@
// For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format
import storybook from "eslint-plugin-storybook";
import prettier from "eslint-config-prettier";
import path from "node:path";
import { includeIgnoreFile } from "@eslint/compat";
import js from "@eslint/js";
import svelte from "eslint-plugin-svelte";
import { defineConfig } from "eslint/config";
import globals from "globals";
import ts from "typescript-eslint";
import svelteConfig from "./svelte.config.js";
const gitignorePath = path.resolve(import.meta.dirname, ".gitignore");
export default defineConfig(
includeIgnoreFile(gitignorePath),
js.configs.recommended,
ts.configs.recommended,
svelte.configs.recommended,
prettier,
svelte.configs.prettier,
{
languageOptions: { globals: { ...globals.browser, ...globals.node } },
rules: {
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
"no-undef": "off"
}
},
{
files: ["**/*.svelte", "**/*.svelte.ts", "**/*.svelte.js"],
languageOptions: {
parserOptions: {
projectService: true,
extraFileExtensions: [".svelte"],
parser: ts.parser,
svelteConfig
}
}
}
);

8100
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

75
web/package.json Normal file
View File

@@ -0,0 +1,75 @@
{
"name": "web",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check . && eslint .",
"format": "prettier --write .",
"test:unit": "vitest",
"test": "npm run test:unit -- --run && npm run test:e2e",
"test:e2e": "playwright test",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"gen:types": "pocketbase-typegen -j ../server/collections.json -o ./src/lib/pocketbase-types.ts"
},
"devDependencies": {
"@chromatic-com/storybook": "^5.1.1",
"@eslint/compat": "^2.0.3",
"@eslint/js": "^10.0.1",
"@fontsource-variable/outfit": "^5.2.8",
"@internationalized/date": "^3.12.0",
"@playwright/test": "^1.58.2",
"@storybook/addon-a11y": "^10.3.3",
"@storybook/addon-docs": "^10.3.3",
"@storybook/addon-svelte-csf": "^5.1.2",
"@storybook/addon-vitest": "^10.3.3",
"@storybook/sveltekit": "^10.3.3",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.50.2",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/forms": "^0.5.11",
"@tailwindcss/vite": "^4.1.18",
"@types/node": "^22",
"@vitest/browser-playwright": "^4.1.0",
"@vitest/coverage-v8": "^4.1.0",
"bits-ui": "^2.16.5",
"clsx": "^2.1.1",
"eslint": "^10.0.3",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-storybook": "^10.3.3",
"eslint-plugin-svelte": "^3.15.2",
"globals": "^17.4.0",
"mode-watcher": "^1.1.0",
"playwright": "^1.58.2",
"pocketbase-typegen": "^1.3.3",
"prettier": "^3.8.1",
"prettier-plugin-svelte": "^3.4.1",
"prettier-plugin-tailwindcss": "^0.7.2",
"remixicon-svelte": "^0.0.5",
"shadcn-svelte": "^1.2.6",
"storybook": "^10.3.3",
"svelte": "^5.54.0",
"svelte-check": "^4.4.2",
"svelte-sonner": "^1.1.0",
"tailwind-merge": "^3.5.0",
"tailwind-variants": "^3.2.2",
"tailwindcss": "^4.1.18",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.57.0",
"vaul-svelte": "^1.0.0-next.7",
"vite": "^7.3.1",
"vitest": "^4.1.0",
"vitest-browser-svelte": "^2.0.2"
},
"dependencies": {
"pocketbase": "^0.26.8"
}
}

6
web/playwright.config.ts Normal file
View File

@@ -0,0 +1,6 @@
import { defineConfig } from "@playwright/test";
export default defineConfig({
webServer: { command: "npm run build && npm run preview", port: 4173 },
testMatch: "**/*.e2e.{ts,js}"
});

13
web/src/app.d.ts vendored Normal file
View File

@@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

11
web/src/app.html Normal file
View File

@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import { cn } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
import { Separator } from "$lib/components/ui/separator/index.js";
let {
ref = $bindable(null),
class: className,
orientation = "vertical",
...restProps
}: ComponentProps<typeof Separator> = $props();
</script>
<Separator
bind:ref
data-slot="button-group-separator"
{orientation}
class={cn(
"bg-input relative self-stretch data-[orientation=horizontal]:mx-px data-[orientation=horizontal]:w-auto data-[orientation=vertical]:my-px data-[orientation=vertical]:h-auto",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,28 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
import type { Snippet } from "svelte";
let {
ref = $bindable(null),
class: className,
child,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
child?: Snippet<[{ props: Record<string, unknown> }]>;
} = $props();
const mergedProps = $derived({
...restProps,
class: cn("bg-muted gap-2 rounded-4xl border px-2.5 text-sm font-medium [&_svg:not([class*='size-'])]:size-4 flex items-center [&_svg]:pointer-events-none", className),
"data-slot": "button-group-text",
});
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div bind:this={ref} {...mergedProps}>
{@render mergedProps.children?.()}
</div>
{/if}

View File

@@ -0,0 +1,46 @@
<script lang="ts" module>
import { tv, type VariantProps } from "tailwind-variants";
export const buttonGroupVariants = tv({
base: "has-[>[data-variant=outline]]:[&>input]:border-border has-[>[data-variant=outline]]:[&>input:focus-visible]:border-ring has-[>[data-variant=outline]]:*:data-[slot=input-group]:border-border has-[>[data-variant=outline]]:[&>[data-slot=input-group]:has(:focus-visible)]:border-ring has-[>[data-variant=outline]]:*:data-[slot=select-trigger]:border-border has-[>[data-variant=outline]]:[&>[data-slot=select-trigger]:focus-visible]:border-ring has-[>[data-slot=button-group]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-4xl flex w-fit items-stretch [&>*]:focus-visible:relative [&>*]:focus-visible:z-10 [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
variants: {
orientation: {
horizontal:
"[&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-4xl! [&>[data-slot]]:rounded-r-none [&>[data-slot]~[data-slot]]:rounded-l-none [&>[data-slot]~[data-slot]]:border-l-0",
vertical:
"[&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-4xl! flex-col [&>[data-slot]]:rounded-b-none [&>[data-slot]~[data-slot]]:rounded-t-none [&>[data-slot]~[data-slot]]:border-t-0",
},
},
defaultVariants: {
orientation: "horizontal",
},
});
export type ButtonGroupOrientation = VariantProps<typeof buttonGroupVariants>["orientation"];
</script>
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
orientation = "horizontal",
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
orientation?: ButtonGroupOrientation;
} = $props();
</script>
<div
bind:this={ref}
role="group"
data-slot="button-group"
data-orientation={orientation}
class={cn(buttonGroupVariants({ orientation }), className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,15 @@
import Root, { buttonGroupVariants, type ButtonGroupOrientation } from "./button-group.svelte";
import Text from "./button-group-text.svelte";
import Separator from "./button-group-separator.svelte";
export {
Root,
Text,
Separator,
buttonGroupVariants,
type ButtonGroupOrientation,
//
Root as ButtonGroup,
Text as ButtonGroupText,
Separator as ButtonGroupSeparator,
};

View File

@@ -0,0 +1,82 @@
<script lang="ts" module>
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from "svelte/elements";
import { type VariantProps, tv } from "tailwind-variants";
export const buttonVariants = tv({
base: "focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-4xl border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-3 active:not-aria-[haspopup]:translate-y-px aria-invalid:ring-3 [&_svg:not([class*='size-'])]:size-4 group/button inline-flex shrink-0 items-center justify-center whitespace-nowrap transition-all outline-none select-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/80",
outline: "border-border bg-background hover:bg-muted hover:text-foreground dark:hover:bg-input/30 aria-expanded:bg-muted aria-expanded:text-foreground dark:bg-transparent",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost: "hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground",
destructive: "bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 gap-1.5 px-3 has-data-[icon=inline-end]:pr-2.5 has-data-[icon=inline-start]:pl-2.5",
xs: "h-6 gap-1 px-2.5 text-xs has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1 px-3 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
lg: "h-10 gap-1.5 px-4 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
icon: "size-9",
"icon-xs": "size-6 [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
});
export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
export type ButtonSize = VariantProps<typeof buttonVariants>["size"];
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
WithElementRef<HTMLAnchorAttributes> & {
variant?: ButtonVariant;
size?: ButtonSize;
};
</script>
<script lang="ts">
let {
class: className,
variant = "default",
size = "default",
ref = $bindable(null),
href = undefined,
type = "button",
disabled,
children,
...restProps
}: ButtonProps = $props();
</script>
{#if href}
<a
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ variant, size }), className)}
href={disabled ? undefined : href}
aria-disabled={disabled}
role={disabled ? "link" : undefined}
tabindex={disabled ? -1 : undefined}
{...restProps}
>
{@render children?.()}
</a>
{:else}
<button
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ variant, size }), className)}
{type}
{disabled}
{...restProps}
>
{@render children?.()}
</button>
{/if}

View File

@@ -0,0 +1,17 @@
import Root, {
type ButtonProps,
type ButtonSize,
type ButtonVariant,
buttonVariants,
} from "./button.svelte";
export {
Root,
type ButtonProps as Props,
//
Root as Button,
buttonVariants,
type ButtonProps,
type ButtonSize,
type ButtonVariant,
};

View File

@@ -0,0 +1,11 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
let {
ref = $bindable(null),
type = "button",
...restProps
}: DialogPrimitive.CloseProps = $props();
</script>
<DialogPrimitive.Close bind:ref data-slot="dialog-close" {type} {...restProps} />

View File

@@ -0,0 +1,48 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import DialogPortal from "./dialog-portal.svelte";
import type { Snippet } from "svelte";
import * as Dialog from "./index.js";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
import { Button } from "$lib/components/ui/button/index.js";
import RiCloseLine from 'remixicon-svelte/icons/close-line';
let {
ref = $bindable(null),
class: className,
portalProps,
children,
showCloseButton = true,
...restProps
}: WithoutChildrenOrChild<DialogPrimitive.ContentProps> & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof DialogPortal>>;
children: Snippet;
showCloseButton?: boolean;
} = $props();
</script>
<DialogPortal {...portalProps}>
<Dialog.Overlay />
<DialogPrimitive.Content
bind:ref
data-slot="dialog-content"
class={cn(
"bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 ring-foreground/5 dark:ring-foreground/10 grid max-w-[calc(100%-2rem)] gap-6 rounded-4xl p-6 text-sm shadow-xl ring-1 duration-100 sm:max-w-md fixed top-1/2 left-1/2 z-50 w-full -translate-x-1/2 -translate-y-1/2 outline-none",
className
)}
{...restProps}
>
{@render children?.()}
{#if showCloseButton}
<DialogPrimitive.Close data-slot="dialog-close">
{#snippet child({ props })}
<Button variant="ghost" class="bg-secondary absolute top-4 right-4" size="icon-sm" {...props}>
<RiCloseLine />
<span class="sr-only">Close</span>
</Button>
{/snippet}
</DialogPrimitive.Close>
{/if}
</DialogPrimitive.Content>
</DialogPortal>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: DialogPrimitive.DescriptionProps = $props();
</script>
<DialogPrimitive.Description
bind:ref
data-slot="dialog-description"
class={cn("text-muted-foreground *:[a]:hover:text-foreground text-sm *:[a]:underline *:[a]:underline-offset-3", className)}
{...restProps}
/>

View File

@@ -0,0 +1,32 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
import { Dialog as DialogPrimitive } from "bits-ui";
import { Button } from "$lib/components/ui/button/index.js";
let {
ref = $bindable(null),
class: className,
children,
showCloseButton = false,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
showCloseButton?: boolean;
} = $props();
</script>
<div
bind:this={ref}
data-slot="dialog-footer"
class={cn("gap-2 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
{...restProps}
>
{@render children?.()}
{#if showCloseButton}
<DialogPrimitive.Close>
{#snippet child({ props })}
<Button variant="outline" {...props}>Close</Button>
{/snippet}
</DialogPrimitive.Close>
{/if}
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="dialog-header"
class={cn("gap-1.5 flex flex-col", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: DialogPrimitive.OverlayProps = $props();
</script>
<DialogPrimitive.Overlay
bind:ref
data-slot="dialog-overlay"
class={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/30 duration-100 supports-backdrop-filter:backdrop-blur-sm fixed inset-0 isolate z-50", className)}
{...restProps}
/>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
let { ...restProps }: DialogPrimitive.PortalProps = $props();
</script>
<DialogPrimitive.Portal {...restProps} />

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: DialogPrimitive.TitleProps = $props();
</script>
<DialogPrimitive.Title
bind:ref
data-slot="dialog-title"
class={cn("text-base leading-none font-medium", className)}
{...restProps}
/>

View File

@@ -0,0 +1,11 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
let {
ref = $bindable(null),
type = "button",
...restProps
}: DialogPrimitive.TriggerProps = $props();
</script>
<DialogPrimitive.Trigger bind:ref data-slot="dialog-trigger" {type} {...restProps} />

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
let { open = $bindable(false), ...restProps }: DialogPrimitive.RootProps = $props();
</script>
<DialogPrimitive.Root bind:open {...restProps} />

View File

@@ -0,0 +1,34 @@
import Root from "./dialog.svelte";
import Portal from "./dialog-portal.svelte";
import Title from "./dialog-title.svelte";
import Footer from "./dialog-footer.svelte";
import Header from "./dialog-header.svelte";
import Overlay from "./dialog-overlay.svelte";
import Content from "./dialog-content.svelte";
import Description from "./dialog-description.svelte";
import Trigger from "./dialog-trigger.svelte";
import Close from "./dialog-close.svelte";
export {
Root,
Title,
Portal,
Footer,
Header,
Trigger,
Overlay,
Content,
Description,
Close,
//
Root as Dialog,
Title as DialogTitle,
Portal as DialogPortal,
Footer as DialogFooter,
Header as DialogHeader,
Trigger as DialogTrigger,
Overlay as DialogOverlay,
Content as DialogContent,
Description as DialogDescription,
Close as DialogClose,
};

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Drawer as DrawerPrimitive } from "vaul-svelte";
let { ref = $bindable(null), ...restProps }: DrawerPrimitive.CloseProps = $props();
</script>
<DrawerPrimitive.Close bind:ref data-slot="drawer-close" {...restProps} />

View File

@@ -0,0 +1,33 @@
<script lang="ts">
import { Drawer as DrawerPrimitive } from "vaul-svelte";
import DrawerPortal from "./drawer-portal.svelte";
import DrawerOverlay from "./drawer-overlay.svelte";
import { cn } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
import type { WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
portalProps,
children,
...restProps
}: DrawerPrimitive.ContentProps & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof DrawerPortal>>;
} = $props();
</script>
<DrawerPortal {...portalProps}>
<DrawerOverlay />
<DrawerPrimitive.Content
bind:ref
data-slot="drawer-content"
class={cn("before:bg-popover before:border-border relative flex h-auto flex-col bg-transparent p-4 text-sm before:absolute before:inset-2 before:-z-10 before:rounded-4xl before:border before:shadow-xl data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=left]:sm:max-w-sm data-[vaul-drawer-direction=right]:sm:max-w-sm group/drawer-content fixed z-50", className)}
{...restProps}
>
<div
class="bg-muted mx-auto mt-4 hidden h-1.5 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block bg-muted mx-auto hidden shrink-0 group-data-[vaul-drawer-direction=bottom]/drawer-content:block"
></div>
{@render children?.()}
</DrawerPrimitive.Content>
</DrawerPortal>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { Drawer as DrawerPrimitive } from "vaul-svelte";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: DrawerPrimitive.DescriptionProps = $props();
</script>
<DrawerPrimitive.Description
bind:ref
data-slot="drawer-description"
class={cn("text-muted-foreground text-sm", className)}
{...restProps}
/>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="drawer-footer"
class={cn("gap-2 p-4 mt-auto flex flex-col", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="drawer-header"
class={cn("gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left flex flex-col", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,12 @@
<script lang="ts">
import { Drawer as DrawerPrimitive } from "vaul-svelte";
let {
shouldScaleBackground = true,
open = $bindable(false),
activeSnapPoint = $bindable(null),
...restProps
}: DrawerPrimitive.RootProps = $props();
</script>
<DrawerPrimitive.NestedRoot {shouldScaleBackground} bind:open bind:activeSnapPoint {...restProps} />

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { Drawer as DrawerPrimitive } from "vaul-svelte";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: DrawerPrimitive.OverlayProps = $props();
</script>
<DrawerPrimitive.Overlay
bind:ref
data-slot="drawer-overlay"
class={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/30 supports-backdrop-filter:backdrop-blur-sm fixed inset-0 z-50", className)}
{...restProps}
/>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Drawer as DrawerPrimitive } from "vaul-svelte";
let { ...restProps }: DrawerPrimitive.PortalProps = $props();
</script>
<DrawerPrimitive.Portal {...restProps} />

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { Drawer as DrawerPrimitive } from "vaul-svelte";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: DrawerPrimitive.TitleProps = $props();
</script>
<DrawerPrimitive.Title
bind:ref
data-slot="drawer-title"
class={cn("text-foreground text-base font-medium", className)}
{...restProps}
/>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Drawer as DrawerPrimitive } from "vaul-svelte";
let { ref = $bindable(null), ...restProps }: DrawerPrimitive.TriggerProps = $props();
</script>
<DrawerPrimitive.Trigger bind:ref data-slot="drawer-trigger" {...restProps} />

View File

@@ -0,0 +1,12 @@
<script lang="ts">
import { Drawer as DrawerPrimitive } from "vaul-svelte";
let {
shouldScaleBackground = true,
open = $bindable(false),
activeSnapPoint = $bindable(null),
...restProps
}: DrawerPrimitive.RootProps = $props();
</script>
<DrawerPrimitive.Root {shouldScaleBackground} bind:open bind:activeSnapPoint {...restProps} />

View File

@@ -0,0 +1,38 @@
import Root from "./drawer.svelte";
import Content from "./drawer-content.svelte";
import Description from "./drawer-description.svelte";
import Overlay from "./drawer-overlay.svelte";
import Footer from "./drawer-footer.svelte";
import Header from "./drawer-header.svelte";
import Title from "./drawer-title.svelte";
import NestedRoot from "./drawer-nested.svelte";
import Close from "./drawer-close.svelte";
import Trigger from "./drawer-trigger.svelte";
import Portal from "./drawer-portal.svelte";
export {
Root,
NestedRoot,
Content,
Description,
Overlay,
Footer,
Header,
Title,
Trigger,
Portal,
Close,
//
Root as Drawer,
NestedRoot as DrawerNestedRoot,
Content as DrawerContent,
Description as DrawerDescription,
Overlay as DrawerOverlay,
Footer as DrawerFooter,
Header as DrawerHeader,
Title as DrawerTitle,
Trigger as DrawerTrigger,
Portal as DrawerPortal,
Close as DrawerClose,
};

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="field-content"
class={cn("gap-1 group/field-content flex flex-1 flex-col leading-snug", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,25 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLParagraphElement>> = $props();
</script>
<p
bind:this={ref}
data-slot="field-description"
class={cn(
"text-muted-foreground text-left text-sm [[data-variant=legend]+&]:-mt-1.5 leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance",
"last:mt-0 nth-last-2:-mt-1",
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
className
)}
{...restProps}
>
{@render children?.()}
</p>

View File

@@ -0,0 +1,58 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
import type { Snippet } from "svelte";
let {
ref = $bindable(null),
class: className,
children,
errors,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
children?: Snippet;
errors?: { message?: string }[];
} = $props();
const hasContent = $derived.by(() => {
// has slotted error
if (children) return true;
// no errors
if (!errors || errors.length === 0) return false;
// has an error but no message
if (errors.length === 1 && !errors[0]?.message) {
return false;
}
return true;
});
const isMultipleErrors = $derived(errors && errors.length > 1);
const singleErrorMessage = $derived(errors && errors.length === 1 && errors[0]?.message);
</script>
{#if hasContent}
<div
bind:this={ref}
role="alert"
data-slot="field-error"
class={cn("text-destructive text-sm font-normal", className)}
{...restProps}
>
{#if children}
{@render children()}
{:else if singleErrorMessage}
{singleErrorMessage}
{:else if isMultipleErrors}
<ul class="ml-4 flex list-disc flex-col gap-1">
{#each errors ?? [] as error, index (index)}
{#if error?.message}
<li>{error.message}</li>
{/if}
{/each}
</ul>
{/if}
</div>
{/if}

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="field-group"
class={cn(
"gap-7 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4 group/field-group @container/field-group flex w-full flex-col",
className
)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,25 @@
<script lang="ts">
import { Label } from "$lib/components/ui/label/index.js";
import { cn } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: ComponentProps<typeof Label> = $props();
</script>
<Label
bind:ref
data-slot="field-label"
class={cn(
"has-data-checked:bg-input/30 gap-2 group-data-[disabled=true]/field:opacity-50 has-[>[data-slot=field]]:rounded-2xl has-[>[data-slot=field]]:border *:data-[slot=field]:p-4 group/field-label peer/field-label flex w-fit leading-snug",
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
className
)}
{...restProps}
>
{@render children?.()}
</Label>

View File

@@ -0,0 +1,24 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
variant = "legend",
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLLegendElement>> & {
variant?: "legend" | "label";
} = $props();
</script>
<legend
bind:this={ref}
data-slot="field-legend"
data-variant={variant}
class={cn("mb-3 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base", className)}
{...restProps}
>
{@render children?.()}
</legend>

View File

@@ -0,0 +1,35 @@
<script lang="ts">
import { Separator } from "$lib/components/ui/separator/index.js";
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
import type { Snippet } from "svelte";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
children?: Snippet;
} = $props();
const hasContent = $derived(!!children);
</script>
<div
bind:this={ref}
data-slot="field-separator"
data-content={hasContent}
class={cn("-my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2 relative", className)}
{...restProps}
>
<Separator class="absolute inset-0 top-1/2" />
{#if children}
<span
class="text-muted-foreground px-2 bg-background relative mx-auto block w-fit"
data-slot="field-separator-content"
>
{@render children()}
</span>
{/if}
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLFieldsetAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLFieldsetAttributes> = $props();
</script>
<fieldset
bind:this={ref}
data-slot="field-set"
class={cn("gap-6 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3 flex flex-col", className)}
{...restProps}
>
{@render children?.()}
</fieldset>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="field-label"
class={cn("gap-2 text-sm font-medium group-data-[disabled=true]/field:opacity-50 flex w-fit items-center leading-snug", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,47 @@
<script lang="ts" module>
import { tv, type VariantProps } from "tailwind-variants";
export const fieldVariants = tv({
base: "data-[invalid=true]:text-destructive gap-3 group/field flex w-full",
variants: {
orientation: {
vertical: "cn-field-orientation-vertical flex-col [&>*]:w-full [&>.sr-only]:w-auto",
horizontal:
"cn-field-orientation-horizontal flex-row items-center has-[>[data-slot=field-content]]:items-start [&>[data-slot=field-label]]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
responsive:
"cn-field-orientation-responsive flex-col @md/field-group:flex-row @md/field-group:items-center @md/field-group:has-[>[data-slot=field-content]]:items-start [&>*]:w-full @md/field-group:[&>*]:w-auto [&>.sr-only]:w-auto @md/field-group:[&>[data-slot=field-label]]:flex-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
},
},
defaultVariants: {
orientation: "vertical",
},
});
export type FieldOrientation = VariantProps<typeof fieldVariants>["orientation"];
</script>
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
orientation = "vertical",
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
orientation?: FieldOrientation;
} = $props();
</script>
<div
bind:this={ref}
role="group"
data-slot="field"
data-orientation={orientation}
class={cn(fieldVariants({ orientation }), className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,33 @@
import Field from "./field.svelte";
import Set from "./field-set.svelte";
import Legend from "./field-legend.svelte";
import Group from "./field-group.svelte";
import Content from "./field-content.svelte";
import Label from "./field-label.svelte";
import Title from "./field-title.svelte";
import Description from "./field-description.svelte";
import Separator from "./field-separator.svelte";
import Error from "./field-error.svelte";
export {
Field,
Set,
Legend,
Group,
Content,
Label,
Title,
Description,
Separator,
Error,
//
Set as FieldSet,
Legend as FieldLegend,
Group as FieldGroup,
Content as FieldContent,
Label as FieldLabel,
Title as FieldTitle,
Description as FieldDescription,
Separator as FieldSeparator,
Error as FieldError,
};

View File

@@ -0,0 +1,7 @@
import Root from "./input.svelte";
export {
Root,
//
Root as Input,
};

View File

@@ -0,0 +1,48 @@
<script lang="ts">
import type { HTMLInputAttributes, HTMLInputTypeAttribute } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
type InputType = Exclude<HTMLInputTypeAttribute, "file">;
type Props = WithElementRef<
Omit<HTMLInputAttributes, "type"> &
({ type: "file"; files?: FileList } | { type?: InputType; files?: undefined })
>;
let {
ref = $bindable(null),
value = $bindable(),
type,
files = $bindable(),
class: className,
"data-slot": dataSlot = "input",
...restProps
}: Props = $props();
</script>
{#if type === "file"}
<input
bind:this={ref}
data-slot={dataSlot}
class={cn(
"bg-input/50 focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 h-9 rounded-3xl border border-transparent px-3 py-1 text-base transition-[color,box-shadow,background-color] file:h-7 file:text-sm file:font-medium focus-visible:ring-3 aria-invalid:ring-3 md:text-sm file:text-foreground placeholder:text-muted-foreground w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
className
)}
type="file"
bind:files
bind:value
{...restProps}
/>
{:else}
<input
bind:this={ref}
data-slot={dataSlot}
class={cn(
"bg-input/50 focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 h-9 rounded-3xl border border-transparent px-3 py-1 text-base transition-[color,box-shadow,background-color] file:h-7 file:text-sm file:font-medium focus-visible:ring-3 aria-invalid:ring-3 md:text-sm file:text-foreground placeholder:text-muted-foreground w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{type}
bind:value
{...restProps}
/>
{/if}

View File

@@ -0,0 +1,7 @@
import Root from "./label.svelte";
export {
Root,
//
Root as Label,
};

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { Label as LabelPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: LabelPrimitive.RootProps = $props();
</script>
<LabelPrimitive.Root
bind:ref
data-slot="label"
class={cn(
"gap-2 text-sm leading-none font-medium group-data-[disabled=true]:opacity-50 peer-disabled:opacity-50 flex items-center select-none group-data-[disabled=true]:pointer-events-none peer-disabled:cursor-not-allowed",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,7 @@
import Root from "./separator.svelte";
export {
Root,
//
Root as Separator,
};

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import { Separator as SeparatorPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
"data-slot": dataSlot = "separator",
...restProps
}: SeparatorPrimitive.RootProps = $props();
</script>
<SeparatorPrimitive.Root
bind:ref
data-slot={dataSlot}
class={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px",
// this is different in shadcn/ui but self-stretch breaks things for us
"data-[orientation=vertical]:h-full",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1 @@
export { default as Toaster } from "./sonner.svelte";

View File

@@ -0,0 +1,34 @@
<script lang="ts">
import { Toaster as Sonner, type ToasterProps as SonnerProps } from "svelte-sonner";
import { mode } from "mode-watcher";
import RiLoaderLine from 'remixicon-svelte/icons/loader-line';
import RiCheckboxCircleLine from 'remixicon-svelte/icons/checkbox-circle-line';
import RiErrorWarningLine from 'remixicon-svelte/icons/error-warning-line';
import RiInformationLine from 'remixicon-svelte/icons/information-line';
import RiCloseCircleLine from 'remixicon-svelte/icons/close-circle-line';
let { ...restProps }: SonnerProps = $props();
</script>
<Sonner
theme={mode.current}
class="toaster group"
style="--normal-bg: var(--color-popover); --normal-text: var(--color-popover-foreground); --normal-border: var(--color-border);"
{...restProps}
>
{#snippet loadingIcon()}
<RiLoaderLine class="size-4 animate-spin" />
{/snippet}
{#snippet successIcon()}
<RiCheckboxCircleLine class="size-4" />
{/snippet}
{#snippet errorIcon()}
<RiErrorWarningLine class="size-4" />
{/snippet}
{#snippet infoIcon()}
<RiInformationLine class="size-4" />
{/snippet}
{#snippet warningIcon()}
<RiCloseCircleLine class="size-4" />
{/snippet}
</Sonner>

View File

@@ -0,0 +1 @@
export { default as Spinner } from "./spinner.svelte";

View File

@@ -0,0 +1,18 @@
<script lang="ts">
import { cn } from "$lib/utils.js";
import RiLoaderLine from 'remixicon-svelte/icons/loader-line';
import type { SVGAttributes } from "svelte/elements";
let {
class: className,
role = "status",
// we add name, color, and stroke for compatibility with different icon libraries props
name,
color,
stroke,
"aria-label": ariaLabel = "Loading",
...restProps
}: SVGAttributes<SVGSVGElement> = $props();
</script>
<RiLoaderLine {role} name={name === null ? undefined : name} color={color === null ? undefined : color} stroke={stroke === null ? undefined : stroke} aria-label={ariaLabel} class={cn("size-4 animate-spin", className)} {...restProps} />

20
web/src/lib/index.ts Normal file
View File

@@ -0,0 +1,20 @@
import { dev } from "$app/environment";
import * as env from "$env/static/public";
import PocketBase, { LocalAuthStore } from "pocketbase";
import type { TypedPocketBase } from "./pocketbase-types";
export type { ClientResponseError } from "pocketbase";
const forcePublicAuthStore =
"PUBLIC_PROD_AUTH_STORE" in env &&
typeof env.PUBLIC_PROD_AUTH_STORE === "string" &&
["true", "t", "yes", "y", "1"].includes(env.PUBLIC_PROD_AUTH_STORE.toLowerCase());
export const pb = new PocketBase(
undefined,
dev && !forcePublicAuthStore
? new LocalAuthStore("__pb_superuser_auth__") // When in development, and not forcing the prod auth store, we just hijack the PocketBase Admin UI session
: new LocalAuthStore("cctv_auth")
) as TypedPocketBase;
export function tw(s: TemplateStringsArray, ...args: unknown[]) {
return s.reduce((acc, str, idx) => acc + str + (args[idx] || ""), "");
}

154
web/src/lib/live-mse.ts Normal file
View File

@@ -0,0 +1,154 @@
export const LIVE_FRAME_INIT = 0x01;
export const LIVE_FRAME_MEDIA = 0x02;
export const LIVE_FRAME_ERROR = 0x03;
export type LiveFrame = {
type: number;
payload: Uint8Array;
};
export function decodeLiveFrame(data: ArrayBuffer): LiveFrame | null {
const bytes = new Uint8Array(data);
if (bytes.length === 0) {
return null;
}
return {
type: bytes[0],
payload: bytes.slice(1)
};
}
export class SourceBufferAppender {
private readonly sourceBuffer: SourceBuffer;
private readonly onError: (err: unknown) => void;
private readonly queue: Uint8Array[] = [];
private disposed = false;
constructor(sourceBuffer: SourceBuffer, onError: (err: unknown) => void) {
this.sourceBuffer = sourceBuffer;
this.onError = onError;
this.onUpdateEnd = this.onUpdateEnd.bind(this);
this.sourceBuffer.addEventListener("updateend", this.onUpdateEnd);
}
append(segment: Uint8Array) {
if (this.disposed || segment.length === 0) {
return;
}
this.queue.push(segment);
this.drain();
}
pendingSegments(): number {
return this.queue.length;
}
dispose() {
if (this.disposed) {
return;
}
this.disposed = true;
this.queue.length = 0;
this.sourceBuffer.removeEventListener("updateend", this.onUpdateEnd);
}
private onUpdateEnd() {
this.drain();
}
private drain() {
if (this.disposed || this.sourceBuffer.updating || this.queue.length === 0) {
return;
}
const next = this.queue.shift();
if (!next) {
return;
}
try {
const raw = next.buffer.slice(next.byteOffset, next.byteOffset + next.byteLength) as ArrayBuffer;
this.sourceBuffer.appendBuffer(raw);
} catch (err) {
this.onError(err);
}
}
}
export function pickLiveMimeType(initSegment: Uint8Array): string | null {
const hasAvc1 = includesASCII(initSegment, "avc1");
const hasHvc1 = includesASCII(initSegment, "hvc1");
const hasHev1 = includesASCII(initSegment, "hev1");
const avcCodec = parseAvcCodec(initSegment);
const candidates: string[] = [];
if (hasAvc1 && avcCodec) {
candidates.push(`video/mp4; codecs="${avcCodec},mp4a.40.2"`);
candidates.push(`video/mp4; codecs="${avcCodec}"`);
}
if (hasAvc1) {
candidates.push('video/mp4; codecs="avc1.640028,mp4a.40.2"');
candidates.push('video/mp4; codecs="avc1.42E01E,mp4a.40.2"');
}
if (hasHvc1) {
candidates.push('video/mp4; codecs="hvc1,mp4a.40.2"');
candidates.push('video/mp4; codecs="hvc1"');
}
if (hasHev1) {
candidates.push('video/mp4; codecs="hev1,mp4a.40.2"');
candidates.push('video/mp4; codecs="hev1"');
}
candidates.push("video/mp4");
for (const candidate of candidates) {
if (MediaSource.isTypeSupported(candidate)) {
return candidate;
}
}
return null;
}
export function buildLiveWsUrl(streamId: string): string {
const current = new URL(window.location.href);
const apiBaseParam = current.searchParams.get("apiBase");
const apiBase = apiBaseParam ? new URL(apiBaseParam, current) : current;
const protocol = apiBase.protocol === "https:" ? "wss:" : "ws:";
return `${protocol}//${apiBase.host}/api/cctv/live/ws/${encodeURIComponent(streamId)}`;
}
function parseAvcCodec(data: Uint8Array): string | null {
const idx = indexOfASCII(data, "avcC");
if (idx < 0 || idx+8 >= data.length) {
return null;
}
const profile = data[idx + 5];
const compatibility = data[idx + 6];
const level = data[idx + 7];
return `avc1.${hex2(profile)}${hex2(compatibility)}${hex2(level)}`;
}
function includesASCII(data: Uint8Array, text: string): boolean {
return indexOfASCII(data, text) >= 0;
}
function indexOfASCII(data: Uint8Array, text: string): number {
if (text.length === 0 || text.length > data.length) {
return -1;
}
const codes = Array.from(text, (char) => char.charCodeAt(0));
for (let i = 0; i <= data.length - codes.length; i++) {
let matched = true;
for (let j = 0; j < codes.length; j++) {
if (data[i + j] !== codes[j]) {
matched = false;
break;
}
}
if (matched) {
return i;
}
}
return -1;
}
function hex2(value: number): string {
return value.toString(16).toUpperCase().padStart(2, "0");
}

View File

@@ -0,0 +1,226 @@
/**
* This file was @generated using pocketbase-typegen
*/
import type PocketBase from 'pocketbase'
import type { RecordService } from 'pocketbase'
export enum Collections {
Authorigins = "_authOrigins",
Externalauths = "_externalAuths",
Mfas = "_mfas",
Otps = "_otps",
Superusers = "_superusers",
Cameras = "cameras",
Streams = "streams",
Users = "users",
}
// Alias types for improved usability
export type IsoDateString = string
export type IsoAutoDateString = string & { readonly autodate: unique symbol }
export type RecordIdString = string
export type FileNameString = string & { readonly filename: unique symbol }
export type HTMLString = string
type ExpandType<T> = unknown extends T
? T extends unknown
? { expand?: unknown }
: { expand: T }
: { expand: T }
// System fields
export type BaseSystemFields<T = unknown> = {
id: RecordIdString
collectionId: string
collectionName: Collections
} & ExpandType<T>
export type AuthSystemFields<T = unknown> = {
email: string
emailVisibility: boolean
username: string
verified: boolean
} & BaseSystemFields<T>
// Record types for each collection
export type AuthoriginsRecord = {
collectionRef: string
created: IsoAutoDateString
fingerprint: string
id: string
recordRef: string
updated: IsoAutoDateString
}
export type ExternalauthsRecord = {
collectionRef: string
created: IsoAutoDateString
id: string
provider: string
providerId: string
recordRef: string
updated: IsoAutoDateString
}
export type MfasRecord = {
collectionRef: string
created: IsoAutoDateString
id: string
method: string
recordRef: string
updated: IsoAutoDateString
}
export type OtpsRecord = {
collectionRef: string
created: IsoAutoDateString
id: string
password: string
recordRef: string
sentTo?: string
updated: IsoAutoDateString
}
export type SuperusersRecord = {
created: IsoAutoDateString
email: string
emailVisibility?: boolean
id: string
password: string
tokenKey: string
updated: IsoAutoDateString
verified?: boolean
}
export type CamerasRecord = {
created: IsoAutoDateString
id: string
name: string
onvif_host?: string
password: string
record_stream?: RecordIdString
updated?: IsoAutoDateString
username: string
}
export type StreamsRecord = {
camera: RecordIdString
fps: number
height: number
id: string
url: string
width: number
}
export type UsersRecord = {
created: IsoAutoDateString
email: string
emailVisibility?: boolean
id: string
password: string
tokenKey: string
updated?: IsoAutoDateString
verified?: boolean
}
// Response types include system fields and match responses from the PocketBase API
export type AuthoriginsResponse<Texpand = unknown> = Required<AuthoriginsRecord> & BaseSystemFields<Texpand>
export type ExternalauthsResponse<Texpand = unknown> = Required<ExternalauthsRecord> & BaseSystemFields<Texpand>
export type MfasResponse<Texpand = unknown> = Required<MfasRecord> & BaseSystemFields<Texpand>
export type OtpsResponse<Texpand = unknown> = Required<OtpsRecord> & BaseSystemFields<Texpand>
export type SuperusersResponse<Texpand = unknown> = Required<SuperusersRecord> & AuthSystemFields<Texpand>
export type CamerasResponse<Texpand = unknown> = Required<CamerasRecord> & BaseSystemFields<Texpand>
export type StreamsResponse<Texpand = unknown> = Required<StreamsRecord> & BaseSystemFields<Texpand>
export type UsersResponse<Texpand = unknown> = Required<UsersRecord> & AuthSystemFields<Texpand>
// Types containing all Records and Responses, useful for creating typing helper functions
export type CollectionRecords = {
_authOrigins: AuthoriginsRecord
_externalAuths: ExternalauthsRecord
_mfas: MfasRecord
_otps: OtpsRecord
_superusers: SuperusersRecord
cameras: CamerasRecord
streams: StreamsRecord
users: UsersRecord
}
export type CollectionResponses = {
_authOrigins: AuthoriginsResponse
_externalAuths: ExternalauthsResponse
_mfas: MfasResponse
_otps: OtpsResponse
_superusers: SuperusersResponse
cameras: CamerasResponse
streams: StreamsResponse
users: UsersResponse
}
// Utility types for create/update operations
type ProcessCreateAndUpdateFields<T> = Omit<{
// Omit AutoDate fields
[K in keyof T as Extract<T[K], IsoAutoDateString> extends never ? K : never]:
// Convert FileNameString to File
T[K] extends infer U ?
U extends (FileNameString | FileNameString[]) ?
U extends any[] ? File[] : File
: U
: never
}, 'id'>
// Create type for Auth collections
export type CreateAuth<T> = {
id?: RecordIdString
email: string
emailVisibility?: boolean
password: string
passwordConfirm: string
verified?: boolean
} & ProcessCreateAndUpdateFields<T>
// Create type for Base collections
export type CreateBase<T> = {
id?: RecordIdString
} & ProcessCreateAndUpdateFields<T>
// Update type for Auth collections
export type UpdateAuth<T> = Partial<
Omit<ProcessCreateAndUpdateFields<T>, keyof AuthSystemFields>
> & {
email?: string
emailVisibility?: boolean
oldPassword?: string
password?: string
passwordConfirm?: string
verified?: boolean
}
// Update type for Base collections
export type UpdateBase<T> = Partial<
Omit<ProcessCreateAndUpdateFields<T>, keyof BaseSystemFields>
>
// Get the correct create type for any collection
export type Create<T extends keyof CollectionResponses> =
CollectionResponses[T] extends AuthSystemFields
? CreateAuth<CollectionRecords[T]>
: CreateBase<CollectionRecords[T]>
// Get the correct update type for any collection
export type Update<T extends keyof CollectionResponses> =
CollectionResponses[T] extends AuthSystemFields
? UpdateAuth<CollectionRecords[T]>
: UpdateBase<CollectionRecords[T]>
// Type for usage with type asserted PocketBase instance
// https://github.com/pocketbase/js-sdk#specify-typescript-definitions
export type TypedPocketBase = {
collection<T extends keyof CollectionResponses>(
idOrName: T
): RecordService<CollectionResponses[T]>
} & PocketBase

13
web/src/lib/utils.ts Normal file
View File

@@ -0,0 +1,13 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type WithoutChild<T> = T extends { child?: any } ? Omit<T, "child"> : T;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type WithoutChildren<T> = T extends { children?: any } ? Omit<T, "children"> : T;
export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>;
export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & { ref?: U | null };

View File

@@ -0,0 +1,22 @@
<script lang="ts">
import { resolve } from "$app/paths";
import Button from "$lib/components/ui/button/button.svelte";
import VideoAdd from "remixicon-svelte/icons/video-add-fill";
const { children } = $props();
</script>
<div class="fixed top-0 left-0 flex h-12 w-screen items-center p-2">
<h2 class="flex-1 text-2xl">CCTV</h2>
<Button href={resolve("/new")} variant="outline" size="icon" aria-label="New camera">
<VideoAdd />
</Button>
</div>
<main>
{#each Array(1000) as _, idx (idx)}
<p>Hey!</p>
{/each}
</main>
{@render children()}

Some files were not shown because too many files have changed in this diff Show More