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 optional comments and a playback heatmap from the item's
16// yt-dlp info sidecar. These fields are intentionally not copied into the
17// marker: the sidecar remains the source of truth and old items simply return
18// empty data when the fields are absent.
19func (s *LibraryService) GetEngagement(relPath string) ([]models.Comment, []models.HeatmapSegment, error) {
20 item, err := s.GetByRelPath(relPath)
21 if err != nil {
22 return nil, nil, err
23 }
24 _, infoPath, err := s.listItemFiles(item.DirPath)
25 if err != nil || infoPath == "" {
26 return nil, nil, err
27 }
28 data, err := os.ReadFile(infoPath)
29 if err != nil {
30 return nil, nil, err
31 }
32
33 var raw struct {
34 Comments []struct {
35 ID string `json:"id"`
36 Parent string `json:"parent"`
37 Author string `json:"author"`
38 Channel string `json:"channel"`
39 Text string `json:"text"`
40 Timestamp float64 `json:"timestamp"`
41 TimeText string `json:"time_text"`
42 LikeCount int `json:"like_count"`
43 AuthorIsUploader bool `json:"author_is_uploader"`
44 } `json:"comments"`
45 Heatmap []struct {
46 StartTime float64 `json:"start_time"`
47 EndTime float64 `json:"end_time"`
48 Value float64 `json:"value"`
49 } `json:"heatmap"`
50 }
51 if err := json.Unmarshal(data, &raw); err != nil {
52 return nil, nil, fmt.Errorf("parse engagement info JSON: %w", err)
53 }
54
55 comments := make([]models.Comment, 0, len(raw.Comments))
56 for _, c := range raw.Comments {
57 author := c.Author
58 if author == "" {
59 author = c.Channel
60 }
61 if c.Text == "" && author == "" {
62 continue
63 }
64 comment := models.Comment{
65 ID: c.ID,
66 Parent: c.Parent,
67 Author: author,
68 Text: c.Text,
69 Timestamp: int64(c.Timestamp),
70 TimeText: c.TimeText,
71 LikeCount: c.LikeCount,
72 AuthorIsUploader: c.AuthorIsUploader,
73 }
74 if comment.TimeText == "" && comment.Timestamp > 0 {
75 comment.TimeText = time.Unix(comment.Timestamp, 0).UTC().Format("2006-01-02 15:04")
76 }
77 comments = append(comments, comment)
78 }
79 comments = orderComments(comments)
80
81 maxValue := 0.0
82 for _, h := range raw.Heatmap {
83 if h.Value > maxValue && !math.IsNaN(h.Value) && !math.IsInf(h.Value, 0) {
84 maxValue = h.Value
85 }
86 }
87 endTime := 0.0
88 for _, h := range raw.Heatmap {
89 if h.EndTime > endTime {
90 endTime = h.EndTime
91 }
92 }
93 heatmap := make([]models.HeatmapSegment, 0, len(raw.Heatmap))
94 for _, h := range raw.Heatmap {
95 if h.EndTime <= h.StartTime || h.StartTime < 0 || h.Value < 0 ||
96 math.IsNaN(h.StartTime) || math.IsNaN(h.EndTime) || math.IsNaN(h.Value) ||
97 math.IsInf(h.StartTime, 0) || math.IsInf(h.EndTime, 0) || math.IsInf(h.Value, 0) {
98 continue
99 }
100 width := 0.0
101 if endTime > 0 {
102 width = (h.EndTime - h.StartTime) / endTime * 100
103 }
104 height := 8.0
105 if maxValue > 0 {
106 height += h.Value / maxValue * 92
107 }
108 heatmap = append(heatmap, models.HeatmapSegment{
109 StartTime: h.StartTime,
110 EndTime: h.EndTime,
111 Value: h.Value,
112 Width: width,
113 Height: height,
114 })
115 }
116 sort.SliceStable(heatmap, func(i, j int) bool {
117 return heatmap[i].StartTime < heatmap[j].StartTime
118 })
119 return comments, heatmap, nil
120}
121
122// orderComments groups replies beneath their parent while preserving the
123// source order among siblings. Depth is capped for presentation so malformed
124// or unusually deep reply chains cannot make 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 stack := make([]struct {
158 index int
159 depth int
160 }, 0, len(comments))
161 walk := func(index, depth int) {
162 stack = append(stack, struct {
163 index int
164 depth int
165 }{index, depth})
166 for len(stack) > 0 {
167 last := len(stack) - 1
168 entry := stack[last]
169 stack = stack[:last]
170 if visited[entry.index] {
171 continue
172 }
173 visited[entry.index] = true
174 comment := comments[entry.index]
175 if parentIndex, ok := byID[strings.TrimSpace(comment.Parent)]; ok && parentIndex != entry.index {
176 comment.ReplyTo = comments[parentIndex].Author
177 }
178 if entry.depth > 4 {
179 comment.Depth = 4
180 } else {
181 comment.Depth = entry.depth
182 }
183 ordered = append(ordered, comment)
184
185 childrenForComment := children[entry.index]
186 for i := len(childrenForComment) - 1; i >= 0; i-- {
187 stack = append(stack, struct {
188 index int
189 depth int
190 }{childrenForComment[i], entry.depth + 1})
191 }
192 }
193 }
194
195 for _, root := range roots {
196 walk(root, 0)
197 }
198 // Cycles or references to invalid parents are rendered as top-level comments
199 // rather than being dropped.
200 for i := range comments {
201 if !visited[i] {
202 walk(i, 0)
203 }
204 }
205 return ordered
206}
207