package liveview import ( "context" "crypto/rand" "encoding/hex" "fmt" "net/url" "strings" "sync" "time" "yovision.local/sense/app/sense/admission" "yovision.local/sense/app/sense/device" "yovision.local/sense/app/sense/media" ) type Route struct { ID string `json:"id"` DeviceID string `json:"device_id"` DeviceName string `json:"device_name"` DeviceLocation string `json:"device_location"` ProfileToken string `json:"profile_token"` ProfileName string `json:"profile_name"` ProfileKind string `json:"profile_kind"` Desired string `json:"desired"` Actual string `json:"actual"` Detail string `json:"detail"` Readers int `json:"readers"` } type Session struct { ID string `json:"id"` OwnerID string `json:"-"` RouteID string `json:"route_id"` DeviceID string `json:"device_id"` DeviceName string `json:"device_name"` ProfileToken string `json:"profile_token"` ProfileName string `json:"profile_name"` ProfileKind string `json:"profile_kind"` PlayerURL string `json:"player_url"` Status string `json:"status"` Detail string `json:"detail"` ExpiresAt time.Time `json:"expires_at"` } type Service struct { base *url.URL ttl time.Duration route func(context.Context, string) (media.Route, error) refresh func(context.Context, string) (media.Route, error) routes func(context.Context) ([]media.Route, error) mu sync.RWMutex sessions map[string]Session now func() time.Time } func NewService(rawBase string, ttl time.Duration) (*Service, error) { parsed, err := url.Parse(strings.TrimRight(rawBase, "/")) if err != nil || parsed.Scheme == "" || parsed.Host == "" || parsed.User != nil { return nil, fmt.Errorf("invalid MediaMTX WebRTC base URL") } if parsed.Scheme != "http" && parsed.Scheme != "https" { return nil, fmt.Errorf("unsupported MediaMTX WebRTC scheme") } if ttl <= 0 || ttl > 10*time.Minute { ttl = 2 * time.Minute } return &Service{base: parsed, ttl: ttl, route: media.PlaybackRoute, refresh: media.RefreshPlaybackRoute, routes: media.PlaybackRoutes, sessions: map[string]Session{}, now: time.Now}, nil } func (s *Service) Routes(ctx context.Context) ([]Route, error) { items, err := s.routes(ctx) if err != nil { return nil, err } result := make([]Route, 0, len(items)) for _, item := range items { if item.Desired == "running" { view := Route{ID: item.ID, DeviceID: item.DeviceID, DeviceName: item.DeviceID, ProfileToken: item.ProfileToken, ProfileName: item.ProfileToken, Desired: item.Desired, Actual: item.Actual, Detail: item.Detail, Readers: item.Readers} if info, describeErr := device.Describe(ctx, item.DeviceID); describeErr == nil { view.DeviceName = info.Name view.DeviceLocation = info.Location } if profile, profileErr := admission.VerifiedProfile(item.DeviceID, item.ProfileToken); profileErr == nil { view.ProfileName = profile.Name view.ProfileKind = profile.Kind } result = append(result, view) } } return result, nil } func (s *Service) Create(ctx context.Context, owner, routeID string) (Session, error) { route, err := s.route(ctx, routeID) if err != nil { return Session{}, err } if route.Desired != "running" { return Session{}, fmt.Errorf("media route is stopped") } id := newID() session := Session{ID: id, OwnerID: owner, RouteID: route.ID, DeviceID: route.DeviceID, DeviceName: route.DeviceID, ProfileToken: route.ProfileToken, ProfileName: route.ProfileToken, PlayerURL: "/api/v1/liveview/sessions/" + id + "/player", Status: route.Actual, Detail: route.Detail, ExpiresAt: s.now().UTC().Add(s.ttl)} if info, describeErr := device.Describe(ctx, route.DeviceID); describeErr == nil { session.DeviceName = info.Name } if profile, profileErr := admission.VerifiedProfile(route.DeviceID, route.ProfileToken); profileErr == nil { session.ProfileName = profile.Name session.ProfileKind = profile.Kind } s.mu.Lock() s.sessions[id] = session s.mu.Unlock() return session, nil } func (s *Service) Get(ctx context.Context, owner, id string) (Session, error) { s.mu.RLock() session, ok := s.sessions[id] s.mu.RUnlock() if !ok || session.OwnerID != owner || !s.now().Before(session.ExpiresAt) { return Session{}, fmt.Errorf("playback session expired") } route, err := s.refresh(ctx, session.RouteID) if err != nil { return Session{}, err } session.Status = route.Actual session.Detail = route.Detail return session, nil } func (s *Service) EmbedURL(ctx context.Context, owner, id string) (string, error) { session, err := s.Get(ctx, owner, id) if err != nil { return "", err } route, err := s.route(ctx, session.RouteID) if err != nil { return "", err } target := *s.base target.Path = strings.TrimRight(target.Path, "/") + "/" + route.Path return target.String(), nil } func newID() string { b := make([]byte, 16) if _, err := rand.Read(b); err != nil { panic(err) } return "view_" + hex.EncodeToString(b) }