package liveview import ( "context" "crypto/rand" "encoding/hex" "fmt" "net/url" "strings" "sync" "time" "yovision.local/sense/app/sense/media" ) type Session struct { ID string `json:"id"` OwnerID string `json:"-"` RouteID string `json:"route_id"` DeviceID string `json:"device_id"` ProfileToken string `json:"profile_token"` 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) 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, routes: media.PlaybackRoutes, sessions: map[string]Session{}, now: time.Now}, nil } func (s *Service) Routes(ctx context.Context) ([]media.Route, error) { items, err := s.routes(ctx) if err != nil { return nil, err } result := items[:0] for _, item := range items { if item.Desired == "running" { result = append(result, item) } } 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, ProfileToken: route.ProfileToken, PlayerURL: "/api/v1/liveview/sessions/" + id + "/player", Status: route.Actual, Detail: route.Detail, ExpiresAt: s.now().UTC().Add(s.ttl)} 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.route(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) }