package service import ( "encoding/json" "fmt" "math" "os" "sort" "strings" "time" "vidarchive/internal/models" ) // GetEngagement reads optional comments and a playback heatmap from the item's // yt-dlp info sidecar. These fields are intentionally not copied into the // marker: the sidecar remains the source of truth and old items simply return // empty data when the fields are absent. func (s *LibraryService) GetEngagement(relPath string) ([]models.Comment, []models.HeatmapSegment, error) { item, err := s.GetByRelPath(relPath) if err != nil { return nil, nil, err } _, infoPath, err := s.listItemFiles(item.DirPath) if err != nil || infoPath == "" { return nil, nil, err } data, err := os.ReadFile(infoPath) if err != nil { return nil, nil, err } var raw struct { Comments []struct { ID string `json:"id"` Parent string `json:"parent"` Author string `json:"author"` Channel string `json:"channel"` Text string `json:"text"` Timestamp float64 `json:"timestamp"` TimeText string `json:"time_text"` LikeCount int `json:"like_count"` AuthorIsUploader bool `json:"author_is_uploader"` } `json:"comments"` Heatmap []struct { StartTime float64 `json:"start_time"` EndTime float64 `json:"end_time"` Value float64 `json:"value"` } `json:"heatmap"` } if err := json.Unmarshal(data, &raw); err != nil { return nil, nil, fmt.Errorf("parse engagement info JSON: %w", err) } comments := make([]models.Comment, 0, len(raw.Comments)) for _, c := range raw.Comments { author := c.Author if author == "" { author = c.Channel } if c.Text == "" && author == "" { continue } comment := models.Comment{ ID: c.ID, Parent: c.Parent, Author: author, Text: c.Text, Timestamp: int64(c.Timestamp), TimeText: c.TimeText, LikeCount: c.LikeCount, AuthorIsUploader: c.AuthorIsUploader, } if comment.TimeText == "" && comment.Timestamp > 0 { comment.TimeText = time.Unix(comment.Timestamp, 0).UTC().Format("2006-01-02 15:04") } comments = append(comments, comment) } comments = orderComments(comments) maxValue, endTime := 0.0, 0.0 for _, h := range raw.Heatmap { if h.Value > maxValue && !math.IsNaN(h.Value) && !math.IsInf(h.Value, 0) { maxValue = h.Value } if h.EndTime > endTime { endTime = h.EndTime } } heatmap := make([]models.HeatmapSegment, 0, len(raw.Heatmap)) for _, h := range raw.Heatmap { if h.EndTime <= h.StartTime || h.StartTime < 0 || h.Value < 0 || math.IsNaN(h.StartTime) || math.IsNaN(h.EndTime) || math.IsNaN(h.Value) || math.IsInf(h.StartTime, 0) || math.IsInf(h.EndTime, 0) || math.IsInf(h.Value, 0) { continue } width := 0.0 if endTime > 0 { width = (h.EndTime - h.StartTime) / endTime * 100 } height := 8.0 if maxValue > 0 { height += h.Value / maxValue * 92 } heatmap = append(heatmap, models.HeatmapSegment{ StartTime: h.StartTime, EndTime: h.EndTime, Value: h.Value, Width: width, Height: height, }) } sort.SliceStable(heatmap, func(i, j int) bool { return heatmap[i].StartTime < heatmap[j].StartTime }) return comments, heatmap, nil } // maxCommentDepth caps the rendered indent level; style.css defines // .comment-depth-1 through .comment-depth-4. const maxCommentDepth = 4 // orderComments groups replies beneath their parent while preserving the // source order among siblings. Depth is capped for presentation so malformed // or unusually deep reply chains cannot make the UI progressively narrower. func orderComments(comments []models.Comment) []models.Comment { if len(comments) < 2 { return comments } byID := make(map[string]int, len(comments)) for i, comment := range comments { if comment.ID != "" { if _, exists := byID[comment.ID]; !exists { byID[comment.ID] = i } } } children := make(map[int][]int) var roots []int for i, comment := range comments { parent := strings.TrimSpace(comment.Parent) if parent == "" || strings.EqualFold(parent, "root") { roots = append(roots, i) continue } parentIndex, ok := byID[parent] if !ok || parentIndex == i { roots = append(roots, i) continue } children[parentIndex] = append(children[parentIndex], i) } ordered := make([]models.Comment, 0, len(comments)) visited := make([]bool, len(comments)) // ponytail: plain recursion, so stack depth is the reply-chain length. Swap // in an explicit stack if a sidecar ever carries a chain deep enough to care. var walk func(index, depth int) walk = func(index, depth int) { if visited[index] { return } visited[index] = true comment := comments[index] if parentIndex, ok := byID[strings.TrimSpace(comment.Parent)]; ok && parentIndex != index { comment.ReplyTo = comments[parentIndex].Author } comment.Depth = min(depth, maxCommentDepth) ordered = append(ordered, comment) for _, child := range children[index] { walk(child, depth+1) } } for _, root := range roots { walk(root, 0) } // Cycles or references to invalid parents are rendered as top-level comments // rather than being dropped. Already-walked indices are a no-op. for i := range comments { walk(i, 0) } return ordered }