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, endTime := 0.0, 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 if h.EndTime > endTime {
87 endTime = h.EndTime
88 }
89 }
90 heatmap := make([]models.HeatmapSegment, 0, len(raw.Heatmap))
91 for _, h := range raw.Heatmap {
92 if h.EndTime <= h.StartTime || h.StartTime < 0 || h.Value < 0 ||
93 math.IsNaN(h.StartTime) || math.IsNaN(h.EndTime) || math.IsNaN(h.Value) ||
94 math.IsInf(h.StartTime, 0) || math.IsInf(h.EndTime, 0) || math.IsInf(h.Value, 0) {
95 continue
96 }
97 width := 0.0
98 if endTime > 0 {
99 width = (h.EndTime - h.StartTime) / endTime * 100
100 }
101 height := 8.0
102 if maxValue > 0 {
103 height += h.Value / maxValue * 92
104 }
105 heatmap = append(heatmap, models.HeatmapSegment{
106 StartTime: h.StartTime,
107 EndTime: h.EndTime,
108 Value: h.Value,
109 Width: width,
110 Height: height,
111 })
112 }
113 sort.SliceStable(heatmap, func(i, j int) bool {
114 return heatmap[i].StartTime < heatmap[j].StartTime
115 })
116 return comments, heatmap, nil
117}
118
119// maxCommentDepth caps the rendered indent level; style.css defines
120// .comment-depth-1 through .comment-depth-4.
121const maxCommentDepth = 4
122
123// orderComments groups replies beneath their parent while preserving the
124// source order among siblings. Depth is capped for presentation so malformed
125// or unusually deep reply chains cannot make the UI progressively narrower.
126func orderComments(comments []models.Comment) []models.Comment {
127 if len(comments) < 2 {
128 return comments
129 }
130
131 byID := make(map[string]int, len(comments))
132 for i, comment := range comments {
133 if comment.ID != "" {
134 if _, exists := byID[comment.ID]; !exists {
135 byID[comment.ID] = i
136 }
137 }
138 }
139
140 children := make(map[int][]int)
141 var roots []int
142 for i, comment := range comments {
143 parent := strings.TrimSpace(comment.Parent)
144 if parent == "" || strings.EqualFold(parent, "root") {
145 roots = append(roots, i)
146 continue
147 }
148 parentIndex, ok := byID[parent]
149 if !ok || parentIndex == i {
150 roots = append(roots, i)
151 continue
152 }
153 children[parentIndex] = append(children[parentIndex], i)
154 }
155
156 ordered := make([]models.Comment, 0, len(comments))
157 visited := make([]bool, len(comments))
158 // ponytail: plain recursion, so stack depth is the reply-chain length. Swap
159 // in an explicit stack if a sidecar ever carries a chain deep enough to care.
160 var walk func(index, depth int)
161 walk = func(index, depth int) {
162 if visited[index] {
163 return
164 }
165 visited[index] = true
166 comment := comments[index]
167 if parentIndex, ok := byID[strings.TrimSpace(comment.Parent)]; ok && parentIndex != index {
168 comment.ReplyTo = comments[parentIndex].Author
169 }
170 comment.Depth = min(depth, maxCommentDepth)
171 ordered = append(ordered, comment)
172 for _, child := range children[index] {
173 walk(child, depth+1)
174 }
175 }
176
177 for _, root := range roots {
178 walk(root, 0)
179 }
180 // Cycles or references to invalid parents are rendered as top-level comments
181 // rather than being dropped. Already-walked indices are a no-op.
182 for i := range comments {
183 walk(i, 0)
184 }
185 return ordered
186}
187