167 lines
5.0 KiB
Go
167 lines
5.0 KiB
Go
package ingest
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/url"
|
|
"sync"
|
|
"time"
|
|
|
|
"git.koval.net/cyclane/cctv/server/models"
|
|
"github.com/pocketbase/pocketbase/core"
|
|
"github.com/pocketbase/pocketbase/tools/store"
|
|
"golang.org/x/sync/errgroup"
|
|
)
|
|
|
|
type Ingest struct {
|
|
sync.WaitGroup
|
|
app core.App
|
|
log *slog.Logger
|
|
pipelines *store.Store[string, *activePipeline]
|
|
}
|
|
|
|
type activePipeline struct {
|
|
stream *models.Stream
|
|
pipeline *CameraPipeline
|
|
cancel context.CancelFunc
|
|
terminated <-chan struct{}
|
|
}
|
|
|
|
func BeginIngest(ctx context.Context, app core.App) *Ingest {
|
|
log := app.Logger().With("svc", "ingest")
|
|
ingest := &Ingest{
|
|
WaitGroup: sync.WaitGroup{},
|
|
app: app,
|
|
log: log,
|
|
pipelines: store.New[string, *activePipeline](nil),
|
|
}
|
|
|
|
// initialise thumbnail pipelines
|
|
thumbnailStreams := map[string]*models.Stream{}
|
|
streams := []*models.Stream{}
|
|
err := app.RunInTransaction(func(txApp core.App) error {
|
|
if err := txApp.RecordQuery("streams").All(&streams); err != nil {
|
|
return fmt.Errorf("failed to fetch stream records: %w", err)
|
|
}
|
|
for _, stream := range streams {
|
|
other, ok := thumbnailStreams[stream.CameraID()]
|
|
if !ok || thumbnailStreamScore(stream) < thumbnailStreamScore(other) {
|
|
thumbnailStreams[stream.CameraID()] = stream
|
|
}
|
|
}
|
|
|
|
streamRecords := make([]*core.Record, len(streams))
|
|
for i, stream := range streams {
|
|
streamRecords[i] = stream.Record
|
|
}
|
|
if errs := txApp.ExpandRecords(streamRecords, []string{"camera"}, nil); len(errs) > 0 {
|
|
return fmt.Errorf("failed to expand camera relation for stream records: %v", errs)
|
|
}
|
|
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
log.Error("Failed to initialize stream pipelines", "error", err)
|
|
return ingest
|
|
}
|
|
|
|
group := errgroup.Group{}
|
|
for _, stream := range streams {
|
|
group.Go(func() error {
|
|
log := log.With("stream", stream.Id)
|
|
rtspUrl, err := url.Parse(stream.URL())
|
|
if err != nil {
|
|
return fmt.Errorf("failed to parse stream URL for stream %s: %w", stream.Id, err)
|
|
}
|
|
password, err := stream.Camera().Password(app.EncryptionEnv())
|
|
if err != nil {
|
|
return fmt.Errorf("failed to decrypt camera password for stream %s: %w", stream.Id, err)
|
|
}
|
|
rtspUrl.User = url.UserPassword(stream.Camera().Username(), password)
|
|
pipeline, err := CreatePipeline(log)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create pipeline for stream %s: %w", stream.Id, err)
|
|
}
|
|
if err := pipeline.addRtspSource(rtspUrl); err != nil {
|
|
return fmt.Errorf("failed to add RTSP source to pipeline for stream %s: %w", stream.Id, err)
|
|
}
|
|
consumers := false
|
|
if thumbnailStream, ok := thumbnailStreams[stream.CameraID()]; ok && thumbnailStream.Id == stream.Id {
|
|
consumers = true
|
|
if err := pipeline.addThumbnailBranch(480, 270, 1000*time.Millisecond, 20*time.Second); err != nil {
|
|
return fmt.Errorf("failed to add thumbnail branch to pipeline for stream %s: %w", stream.Id, err)
|
|
}
|
|
}
|
|
if err := pipeline.addLiveBranch(20 * time.Second); err != nil {
|
|
return fmt.Errorf("failed to add live branch to pipeline for stream %s: %w", stream.Id, err)
|
|
}
|
|
|
|
if consumers {
|
|
log.Info("Starting pipeline for stream with consumers.")
|
|
} else {
|
|
log.Info("Stream has no consumers, ignoring pipeline.")
|
|
pipeline.Close()
|
|
return nil
|
|
}
|
|
pipelineCtx, cancel := context.WithCancel(ctx)
|
|
terminated := make(chan struct{})
|
|
ingest.pipelines.Set(stream.Id, &activePipeline{
|
|
stream: stream,
|
|
pipeline: pipeline,
|
|
cancel: cancel,
|
|
terminated: terminated,
|
|
})
|
|
ingest.WaitGroup.Go(func() {
|
|
if err := pipeline.Run(pipelineCtx); err != nil {
|
|
log.Error("Stream pipeline exited with error", "error", err)
|
|
}
|
|
close(terminated)
|
|
})
|
|
return nil
|
|
})
|
|
}
|
|
if err := group.Wait(); err != nil {
|
|
log.Error("Failed to start ingest pipelines", "error", err)
|
|
}
|
|
|
|
return ingest
|
|
}
|
|
|
|
func (ingest *Ingest) GetThumbnail(ctx context.Context, streamId string) (*bytes.Reader, error) {
|
|
active, ok := ingest.pipelines.GetOk(streamId)
|
|
if !ok {
|
|
return nil, fmt.Errorf("no active pipeline for stream %s", streamId)
|
|
}
|
|
thumb, err := active.pipeline.GetThumbnail(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get thumbnail from pipeline for stream %s: %w", streamId, err)
|
|
}
|
|
return thumb, nil
|
|
}
|
|
|
|
func thumbnailStreamScore(stream *models.Stream) float64 {
|
|
return stream.FPS() * float64(stream.Height()*stream.Width())
|
|
}
|
|
|
|
func (ingest *Ingest) SubscribeLive(ctx context.Context, streamId string) (<-chan *bytes.Reader, error) {
|
|
active, ok := ingest.pipelines.GetOk(streamId)
|
|
if !ok {
|
|
return nil, fmt.Errorf("no active pipeline for stream %s", streamId)
|
|
}
|
|
id, stream, err := active.pipeline.LiveSubscribe(1*time.Second, 16)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to subscribe to live stream %s: %w", streamId, err)
|
|
}
|
|
defer active.pipeline.LiveUnsubscribe(id)
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, nil
|
|
case buf := <-stream:
|
|
ingest.log.Debug("Received live stream chunk", "streamId", streamId, "chunkSize", buf.Len())
|
|
}
|
|
}
|
|
}
|