engagement.go
⎇
Raw
1package service
2
3import (
4 "encoding/json"
5 "fmt"
6 "math"
7 "os"
8 "sort"
9 "strings"
10 "time"
11
12 "vidarchive/internal/models"
13)
14
15// GetEngagement reads comments and a playback heatmap from the yt-dlp sidecar,
16// which stays their source of truth. Nothing is copied into the marker, so an
17// old item just returns empty data.
18func (s *LibraryService) GetEngagement(relPath string) ([]models.Comment, []models.HeatmapSegment, error) {
19 item, err := s.GetByRelPath(relPath)
20 if err != nil {
21 return nil, nil, err
22 }
23 _, infoPath, err := s.listItemFiles(item.DirPath)
24 if err != nil || infoPath == "" {
25 return nil, nil, err
26 }
27 data, err := os.ReadFile(infoPath)
28 if err != nil {
29 return nil, nil, err
30 }
31
32 var raw struct {
33 Comments []struct {
34 ID string `json:"id"`
35 Parent string `json:"parent"`
36 Author string `json:"author"`
37 Channel string `json:"channel"`
38 Text string `json:"text"`
39 Timestamp float64 `json:"timestamp"`
40 TimeText string `json:"time_text"`
41 LikeCount int `json:"like_count"`
42 AuthorIsUploader bool `json:"author_is_uploader"`
43 } `json:"comments"`
44 Heatmap []struct {
45 StartTime float64 `json:"start_time"`
46 EndTime float64 `json:"end_time"`
47 Value float64 `json:"value"`
48 } `json:"heatmap"`
49 }
50 if err := json.Unmarshal(data, &raw); err != nil {
51 return nil, nil, fmt.Errorf("parse engagement info JSON: %w", err)
52 }
53
54 comments := make([]models.Comment, 0, len(raw.Comments))
55 for _, c := range raw.Comments {
56 author := c.Author
57 if author == "" {
58 author = c.Channel
59 }
60 if c.Text == "" && author == "" {
61 continue
62 }
63 comment := models.Comment{
64 ID: c.ID,
65 Parent: c.Parent,
66 Author: author,
67 Text: c.Text,
68 Timestamp: int64(c.Timestamp),
69 TimeText: c.TimeText,
70 LikeCount: c.LikeCount,
71 AuthorIsUploader: c.AuthorIsUploader,
72 }
73 if comment.TimeText == "" && comment.Timestamp > 0 {
74 comment.TimeText = time.Unix(comment.Timestamp, 0).UTC().Format("2006-01-02 15:04")
75 }
76 comments = append(comments, comment)
77 }
78 comments = orderComments(comments)
79
80 maxValue, endTime := 0.0, 0.0
81 for _, h := range raw.Heatmap {
82 if h.Value > maxValue && !math.IsNaN(h.Value) && !math.IsInf(h.Value, 0) {
83 maxValue = h.Value
84 }
85 if h.EndTime > endTime {
86 endTime = h.EndTime
87 }
88 }
89 heatmap := make([]models.HeatmapSegment, 0, len(raw.Heatmap))
90 for _, h := range raw.Heatmap {
91 if h.EndTime <= h.StartTime || h.StartTime < 0 || h.Value < 0 ||
92 math.IsNaN(h.StartTime) || math.IsNaN(h.EndTime) || math.IsNaN(h.Value) ||
93 math.IsInf(h.StartTime, 0) || math.IsInf(h.EndTime, 0) || math.IsInf(h.Value, 0) {
94 continue
95 }
96 width := 0.0
97 if endTime > 0 {
98 width = (h.EndTime - h.StartTime) / endTime * 100
99 }
100 height := 8.0
101 if maxValue > 0 {
102 height += h.Value / maxValue * 92
103 }
104 heatmap = append(heatmap, models.HeatmapSegment{
105 StartTime: h.StartTime,
106 EndTime: h.EndTime,
107 Value: h.Value,
108 Width: width,
109 Height: height,
110 })
111 }
112 sort.SliceStable(heatmap, func(i, j int) bool {
113 return heatmap[i].StartTime < heatmap[j].StartTime
114 })
115 return comments, heatmap, nil
116}
117
118// maxCommentDepth caps the rendered indent level; style.css defines
119// .comment-depth-1 through .comment-depth-4.
120const maxCommentDepth = 4
121
122// orderComments groups replies under their parent, keeping the source order
123// among siblings. Depth is capped so a deep or malformed reply chain cannot make
124// the UI progressively narrower.
125func orderComments(comments []models.Comment) []models.Comment {
126 if len(comments) < 2 {
127 return comments
128 }
129
130 byID := make(map[string]int, len(comments))
131 for i, comment := range comments {
132 if comment.ID != "" {
133 if _, exists := byID[comment.ID]; !exists {
134 byID[comment.ID] = i
135 }
136 }
137 }
138
139 children := make(map[int][]int)
140 var roots []int
141 for i, comment := range comments {
142 parent := strings.TrimSpace(comment.Parent)
143 if parent == "" || strings.EqualFold(parent, "root") {
144 roots = append(roots, i)
145 continue
146 }
147 parentIndex, ok := byID[parent]
148 if !ok || parentIndex == i {
149 roots = append(roots, i)
150 continue
151 }
152 children[parentIndex] = append(children[parentIndex], i)
153 }
154
155 ordered := make([]models.Comment, 0, len(comments))
156 visited := make([]bool, len(comments))
157 // ponytail: plain recursion, so stack depth is the reply-chain length. Swap
158 // in an explicit stack if a sidecar ever carries a chain deep enough to care.
159 var walk func(index, depth int)
160 walk = func(index, depth int) {
161 if visited[index] {
162 return
163 }
164 visited[index] = true
165 comment := comments[index]
166 if parentIndex, ok := byID[strings.TrimSpace(comment.Parent)]; ok && parentIndex != index {
167 comment.ReplyTo = comments[parentIndex].Author
168 }
169 comment.Depth = min(depth, maxCommentDepth)
170 ordered = append(ordered, comment)
171 for _, child := range children[index] {
172 walk(child, depth+1)
173 }
174 }
175
176 for _, root := range roots {
177 walk(root, 0)
178 }
179 // Cycles or references to invalid parents are rendered as top-level comments
180 // rather than being dropped. Already-walked indices are a no-op.
181 for i := range comments {
182 walk(i, 0)
183 }
184 return ordered
185}
186