package service import ( "encoding/json" "fmt" "strconv" "strings" ) type ConfigProfileEditor struct { Name string `json:"name"` Path string `json:"path"` PrimaryTemplateName string `json:"primary_template_name"` BusinessName string `json:"business_name"` Description string `json:"description"` OverlayName string `json:"overlay_name"` DeviceCode string `json:"device_code"` SiteName string `json:"site_name"` Queue ConfigProfileQueueEditor `json:"queue"` Instances []ConfigProfileInstanceEditor `json:"instances"` Raw map[string]any `json:"raw"` } type ConfigProfileQueueEditor struct { Size string `json:"size"` Strategy string `json:"strategy"` } func DefaultConfigProfileQueue() ConfigProfileQueueEditor { return ConfigProfileQueueEditor{ Size: "8", Strategy: "drop_oldest", } } type InputBindingEditor struct { VideoSourceRef string `json:"video_source_ref"` } type ServiceBindingEditor struct { ServiceRef string `json:"service_ref"` } type OutputBindingEditor struct { PublishHLSPath string `json:"publish_hls_path"` PublishRTSPPort string `json:"publish_rtsp_port"` PublishRTSPPath string `json:"publish_rtsp_path"` ChannelNo string `json:"channel_no"` } type ConfigProfileInstanceEditor struct { Name string `json:"name"` Template string `json:"template"` VideoSourceRef string `json:"video_source_ref"` DisplayName string `json:"display_name"` SiteName string `json:"site_name"` PublishHLSPath string `json:"publish_hls_path"` PublishRTSPPort string `json:"publish_rtsp_port"` PublishRTSPPath string `json:"publish_rtsp_path"` ChannelNo string `json:"channel_no"` InputBindings map[string]InputBindingEditor `json:"input_bindings,omitempty"` ServiceBindings map[string]ServiceBindingEditor `json:"service_bindings,omitempty"` OutputBindings map[string]OutputBindingEditor `json:"output_bindings,omitempty"` AdvancedParams map[string]any `json:"advanced_params"` Delete bool `json:"delete"` } func (s *ConfigPreviewService) GetProfileEditor(name string) (*ConfigProfileEditor, error) { raw, path, err := s.readAssetJSON("profiles", name) if err != nil { return nil, err } queueMap, _ := raw["queue"].(map[string]any) templateName := stringValue(raw["primary_template_name"]) instances, siteName, deviceCode, err := s.loadRecognitionUnitEditors(name, templateName) if err != nil { return nil, err } return &ConfigProfileEditor{ Name: firstString(raw["name"], name), Path: path, PrimaryTemplateName: stringValue(raw["primary_template_name"]), BusinessName: stringValue(raw["business_name"]), Description: stringValue(raw["description"]), OverlayName: firstOverlayName(raw), DeviceCode: deviceCode, SiteName: siteName, Queue: ConfigProfileQueueEditor{ Size: valueString(queueMap["size"]), Strategy: stringValue(queueMap["strategy"]), }, Instances: instances, Raw: raw, }, nil } func (s *ConfigPreviewService) BuildProfileDocument(editor ConfigProfileEditor) (map[string]any, error) { name := strings.TrimSpace(editor.Name) if name == "" { return nil, fmt.Errorf("profile name is required") } if err := validateConfigName(name); err != nil { return nil, fmt.Errorf("invalid profile name: %w", err) } seen := map[string]struct{}{} instances := make([]map[string]any, 0, len(editor.Instances)) for _, inst := range editor.Instances { if inst.Delete { continue } instanceName := strings.TrimSpace(inst.Name) if instanceName == "" { return nil, fmt.Errorf("instance name is required") } if err := validateConfigName(instanceName); err != nil { return nil, fmt.Errorf("invalid instance name %q: %w", instanceName, err) } if _, ok := seen[instanceName]; ok { return nil, fmt.Errorf("duplicate instance name: %s", instanceName) } seen[instanceName] = struct{}{} videoSourceRef := strings.TrimSpace(inputBindingRef(inst.InputBindings, "video_input_main")) if videoSourceRef == "" { videoSourceRef = strings.TrimSpace(inst.VideoSourceRef) } if videoSourceRef == "" { return nil, fmt.Errorf("video source is required for %s", instanceName) } params := map[string]any{} for key, value := range cloneMap(inst.AdvancedParams) { params[key] = value } instance := map[string]any{ "name": instanceName, } if len(params) > 0 { instance["params"] = params } setString(instance, "template", inst.Template) sceneMeta := map[string]any{} setString(sceneMeta, "display_name", inst.DisplayName) setString(sceneMeta, "site_name", firstString(inst.SiteName, editor.SiteName)) setString(sceneMeta, "device_code", editor.DeviceCode) if len(sceneMeta) > 0 { instance["scene_meta"] = sceneMeta } inputBindings := buildInputBindingDocument(inst) if len(inputBindings) > 0 { instance["input_bindings"] = inputBindings } serviceBindings := buildServiceBindingDocument(inst) if len(serviceBindings) > 0 { instance["service_bindings"] = serviceBindings } outputBindings, err := buildOutputBindingDocument(inst) if err != nil { return nil, err } if len(outputBindings) > 0 { instance["output_bindings"] = outputBindings } instances = append(instances, instance) } doc := map[string]any{ "name": name, "instances": instances, } setString(doc, "business_name", editor.BusinessName) setString(doc, "description", editor.Description) if overlayName := strings.TrimSpace(editor.OverlayName); overlayName != "" { doc["overlays"] = []any{overlayName} } normalizedQueue := editor.Queue if strings.TrimSpace(normalizedQueue.Size) == "" || strings.TrimSpace(normalizedQueue.Strategy) == "" { normalizedQueue = DefaultConfigProfileQueue() } queue := map[string]any{} if size := strings.TrimSpace(normalizedQueue.Size); size != "" { value, err := strconv.Atoi(size) if err != nil { return nil, fmt.Errorf("queue size must be a number") } queue["size"] = value } setString(queue, "strategy", normalizedQueue.Strategy) if len(queue) > 0 { doc["queue"] = queue } return doc, nil } func (s *ConfigPreviewService) SaveProfileEditor(editor ConfigProfileEditor) error { doc, err := s.buildSceneTemplateDocument(editor) if err != nil { return err } body, err := marshalConfigJSON(doc) if err != nil { return err } if s != nil && s.assets != nil { templateName := strings.TrimSpace(editor.PrimaryTemplateName) if templateName == "" { templateName = firstProfileTemplate(editor.Instances) } return s.assets.SaveProfile( strings.TrimSpace(editor.Name), templateName, strings.TrimSpace(editor.BusinessName), strings.TrimSpace(editor.Description), string(body), ) } return fmt.Errorf("asset repository is not configured") } func (s *ConfigPreviewService) buildSceneTemplateDocument(editor ConfigProfileEditor) (map[string]any, error) { name := strings.TrimSpace(editor.Name) if name == "" { return nil, fmt.Errorf("scene template name is required") } if err := validateConfigName(name); err != nil { return nil, fmt.Errorf("invalid scene template name: %w", err) } doc := map[string]any{ "name": name, } setString(doc, "primary_template_name", editor.PrimaryTemplateName) setString(doc, "business_name", editor.BusinessName) setString(doc, "description", editor.Description) setString(doc, "site_name", editor.SiteName) if overlayName := strings.TrimSpace(editor.OverlayName); overlayName != "" { doc["overlays"] = []any{overlayName} } normalizedQueue := editor.Queue if strings.TrimSpace(normalizedQueue.Size) == "" || strings.TrimSpace(normalizedQueue.Strategy) == "" { normalizedQueue = DefaultConfigProfileQueue() } queue := map[string]any{} if size := strings.TrimSpace(normalizedQueue.Size); size != "" { value, err := strconv.Atoi(size) if err != nil { return nil, fmt.Errorf("queue size must be a number") } queue["size"] = value } setString(queue, "strategy", normalizedQueue.Strategy) if len(queue) > 0 { doc["queue"] = queue } return doc, nil } func setString(m map[string]any, key string, value string) { if strings.TrimSpace(value) != "" { m[key] = strings.TrimSpace(value) } } func firstOverlayName(raw map[string]any) string { items, _ := raw["overlays"].([]any) for _, item := range items { if v := stringValue(item); strings.TrimSpace(v) != "" { return strings.TrimSpace(v) } } return "" } func marshalConfigJSON(doc map[string]any) ([]byte, error) { body, err := json.MarshalIndent(doc, "", " ") if err != nil { return nil, err } return append(body, '\n'), nil } func firstProfileTemplate(instances []ConfigProfileInstanceEditor) string { for _, inst := range instances { if inst.Delete { continue } if v := strings.TrimSpace(inst.Template); v != "" { return v } } return "" } func parseInputBindingEditors(raw any) map[string]InputBindingEditor { items, _ := raw.(map[string]any) if len(items) == 0 { return nil } out := map[string]InputBindingEditor{} for key, value := range items { entry, _ := value.(map[string]any) out[key] = InputBindingEditor{ VideoSourceRef: stringValue(entry["video_source_ref"]), } } return out } func parseServiceBindingEditors(raw any) map[string]ServiceBindingEditor { items, _ := raw.(map[string]any) if len(items) == 0 { return nil } out := map[string]ServiceBindingEditor{} for key, value := range items { entry, _ := value.(map[string]any) out[key] = ServiceBindingEditor{ ServiceRef: stringValue(entry["service_ref"]), } } return out } func parseOutputBindingEditors(raw any) map[string]OutputBindingEditor { items, _ := raw.(map[string]any) if len(items) == 0 { return nil } out := map[string]OutputBindingEditor{} for key, value := range items { entry, _ := value.(map[string]any) out[key] = OutputBindingEditor{ PublishHLSPath: stringValue(entry["publish_hls_path"]), PublishRTSPPort: valueString(entry["publish_rtsp_port"]), PublishRTSPPath: stringValue(entry["publish_rtsp_path"]), ChannelNo: stringValue(entry["channel_no"]), } } return out } func inputBindingRef(bindings map[string]InputBindingEditor, slot string) string { if len(bindings) == 0 { return "" } return strings.TrimSpace(bindings[slot].VideoSourceRef) } func outputBindingValue(bindings map[string]OutputBindingEditor, slot string, field string) string { if len(bindings) == 0 { return "" } item, ok := bindings[slot] if !ok { return "" } switch field { case "publish_hls_path": return strings.TrimSpace(item.PublishHLSPath) case "publish_rtsp_port": return strings.TrimSpace(item.PublishRTSPPort) case "publish_rtsp_path": return strings.TrimSpace(item.PublishRTSPPath) case "channel_no": return strings.TrimSpace(item.ChannelNo) default: return "" } } func buildInputBindingDocument(inst ConfigProfileInstanceEditor) map[string]any { out := map[string]any{} for key, value := range inst.InputBindings { entry := map[string]any{} setString(entry, "video_source_ref", value.VideoSourceRef) if len(entry) > 0 { out[key] = entry } } if strings.TrimSpace(inst.VideoSourceRef) != "" { if _, ok := out["video_input_main"]; !ok { out["video_input_main"] = map[string]any{"video_source_ref": strings.TrimSpace(inst.VideoSourceRef)} } } if len(out) == 0 { return nil } return out } func buildServiceBindingDocument(inst ConfigProfileInstanceEditor) map[string]any { out := map[string]any{} for key, value := range inst.ServiceBindings { entry := map[string]any{} setString(entry, "service_ref", value.ServiceRef) if len(entry) > 0 { out[key] = entry } } if len(out) == 0 { return nil } return out } func buildOutputBindingDocument(inst ConfigProfileInstanceEditor) (map[string]any, error) { out := map[string]any{} for key, value := range inst.OutputBindings { if key == "stream_output_main" { value = applyDefaultStreamOutputBinding(inst.Name, value) } entry, err := outputBindingEntry(value) if err != nil { return nil, err } if len(entry) > 0 { out[key] = entry } } if _, ok := out["stream_output_main"]; !ok { entry, err := outputBindingEntry(applyDefaultStreamOutputBinding(inst.Name, OutputBindingEditor{ PublishHLSPath: inst.PublishHLSPath, PublishRTSPPort: inst.PublishRTSPPort, PublishRTSPPath: inst.PublishRTSPPath, ChannelNo: inst.ChannelNo, })) if err != nil { return nil, err } if len(entry) > 0 { out["stream_output_main"] = entry } } if len(out) == 0 { return nil, nil } return out, nil } func applyDefaultStreamOutputBinding(instanceName string, value OutputBindingEditor) OutputBindingEditor { name := strings.TrimSpace(instanceName) if name == "" { return value } if strings.TrimSpace(value.PublishHLSPath) == "" { value.PublishHLSPath = "./web/hls/" + name + "/index.m3u8" } if strings.TrimSpace(value.PublishRTSPPath) == "" { value.PublishRTSPPath = "/live/" + name } if strings.TrimSpace(value.ChannelNo) == "" { value.ChannelNo = name } return value } func outputBindingEntry(value OutputBindingEditor) (map[string]any, error) { entry := map[string]any{} setString(entry, "publish_hls_path", value.PublishHLSPath) setString(entry, "publish_rtsp_path", value.PublishRTSPPath) setString(entry, "channel_no", value.ChannelNo) if port := strings.TrimSpace(value.PublishRTSPPort); port != "" { parsed, err := strconv.Atoi(port) if err != nil { return nil, fmt.Errorf("publish rtsp port must be a number") } entry["publish_rtsp_port"] = parsed } return entry, nil }