jellyfin-tools/jellyfin-year-review/internal/generate/stats.go
jellyfin-year-review/internal/generate/stats.goRaw
1// Package generate computes all owner-visible statistics before publishing a dataset.
2package generate
3
4import (
5 "fmt"
6 "math"
7 "sort"
8 "strings"
9 "time"
10
11 "reelreview/internal/model"
12 "reelreview/internal/source"
13)
14
15type Config struct {
16 Year int
17 Timezone string
18 UserTimezones map[string]string
19 MinimumSeconds float64
20 Completion float64
21 MarathonGap time.Duration
22}
23
24func DefaultConfig(year int) Config {
25 return Config{Year: year, Timezone: "UTC", MinimumSeconds: 60, Completion: 90, MarathonGap: 45 * time.Minute}
26}
27func (c Config) Validate() error {
28 if c.Year < 1970 || c.Year > 9998 || c.MinimumSeconds < 0 || math.IsNaN(c.MinimumSeconds) || math.IsInf(c.MinimumSeconds, 0) || math.IsNaN(c.Completion) || c.Completion <= 0 || c.Completion > 100 || c.MarathonGap < 0 || c.MarathonGap > 24*time.Hour {
29 return fmt.Errorf("invalid generation thresholds or year")
30 }
31 if _, err := time.LoadLocation(c.Timezone); err != nil {
32 return fmt.Errorf("invalid default timezone")
33 }
34 for id, z := range c.UserTimezones {
35 n, err := model.ID(id)
36 if err != nil || n != id {
37 return fmt.Errorf("timezone keys must be normalized UUIDs")
38 }
39 if _, err := time.LoadLocation(z); err != nil {
40 return fmt.Errorf("invalid user timezone")
41 }
42 }
43 return nil
44}
45
46type span struct{ start, end time.Time }
47type mediaSpan struct{ start, end float64 }
48
49func normalizeName(s string) string {
50 s = strings.Join(strings.Fields(s), " ")
51 if s == "" {
52 return "Unknown"
53 }
54 return s
55}
56func named(m map[string]*model.Named) []model.Named {
57 out := []model.Named{}
58 for _, v := range m {
59 out = append(out, *v)
60 }
61 sort.Slice(out, func(i, j int) bool {
62 if out[i].Seconds != out[j].Seconds {
63 return out[i].Seconds > out[j].Seconds
64 }
65 return out[i].Name < out[j].Name
66 })
67 return out
68}
69func add(m map[string]*model.Named, name string, seconds float64) {
70 name = normalizeName(name)
71 key := strings.ToLower(name)
72 v := m[key]
73 if v == nil {
74 v = &model.Named{Name: name}
75 m[key] = v
76 }
77 if name < v.Name {
78 v.Name = name
79 }
80 v.Seconds += seconds
81 v.Records++
82}
83func content(m map[string]*model.Content) []model.Content {
84 out := []model.Content{}
85 for _, v := range m {
86 sort.Ints(v.Months)
87 out = append(out, *v)
88 }
89 sort.Slice(out, func(i, j int) bool {
90 if out[i].Seconds != out[j].Seconds {
91 return out[i].Seconds > out[j].Seconds
92 }
93 if out[i].Records != out[j].Records {
94 return out[i].Records > out[j].Records
95 }
96 return out[i].ID < out[j].ID
97 })
98 return out
99}
100
101// Compute clips exact intervals at local year boundaries. Aggregate-only records
102// are explicitly attributed to their observed end year; no history is inferred.
103func Compute(s model.Snapshot, c Config) ([]model.Review, error) {
104 if err := c.Validate(); err != nil {
105 return nil, err
106 }
107 if err := source.Validate(&s); err != nil {
108 return nil, err
109 }
110 items := map[string]model.Item{}
111 for _, i := range s.Items {
112 items[i.ID] = i
113 }
114 out := []model.Review{}
115 for _, u := range s.Users {
116 zone := c.Timezone
117 if z := c.UserTimezones[u.ID]; z != "" {
118 zone = z
119 }
120 loc, _ := time.LoadLocation(zone)
121 begin := time.Date(c.Year, 1, 1, 0, 0, 0, 0, loc)
122 end := begin.AddDate(1, 0, 0)
123 r := model.Review{Rules: model.Rules{MinimumSeconds: c.MinimumSeconds, CompletionPercent: c.Completion, MarathonGapSeconds: c.MarathonGap.Seconds()}, Version: 1, User: u, Year: c.Year, Timezone: zone, GeneratedAt: s.CapturedAt, Source: s.Source, Coverage: s.Coverage, Availability: map[string]model.Availability{}, Warnings: []string{}, Movies: []model.Content{}, Shows: []model.Content{}, Abandoned: []model.Content{}, DistributionMetric: "watched minutes"}
124 movies, shows := map[string]*model.Content{}, map[string]*model.Content{}
125 genres, devices, clients := map[string]*model.Named{}, map[string]*model.Named{}, map[string]*model.Named{}
126 positions := map[string][]mediaSpan{}
127 positionUnknown := map[string]bool{}
128 unique := map[string]bool{}
129 days := map[string]bool{}
130 intervals := []span{}
131 exact := true
132 completionKnown := true
133 deleted := 0
134 aggregate := 0
135 for _, p := range s.Playback {
136 if p.UserID != u.ID || p.Seconds < c.MinimumSeconds {
137 continue
138 }
139 a, b := p.Start, p.End
140 seconds := p.Seconds
141 if p.Exact {
142 if !b.After(begin) || !a.Before(end) {
143 continue
144 }
145 if a.Before(begin) {
146 a = begin
147 }
148 if b.After(end) {
149 b = end
150 }
151 seconds = b.Sub(a).Seconds()
152 } else {
153 if p.ObservedAt.Before(begin) || !p.ObservedAt.Before(end) {
154 continue
155 }
156 }
157 item, ok := items[p.ItemID]
158 if !ok {
159 deleted++
160 continue
161 }
162 if item.Type != "Movie" && item.Type != "Episode" {
163 continue
164 }
165 if !p.Exact {
166 aggregate++
167 exact = false
168 }
169 r.Seconds += seconds
170 r.Records++
171 unique[item.ID] = true
172 key, title, poster := item.ID, item.Title, item.Poster
173 m := movies
174 if item.Type == "Episode" {
175 r.TVSeconds += seconds
176 m = shows
177 if item.SeriesID != "" {
178 key = item.SeriesID
179 title = item.SeriesTitle
180 if series, ok := items[key]; ok {
181 title = series.Title
182 poster = series.Poster
183 if len(item.Genres) == 0 {
184 item.Genres = series.Genres
185 }
186 }
187 }
188 } else {
189 r.MovieSeconds += seconds
190 }
191 if title == "" {
192 title = "Unknown title"
193 }
194 v := m[key]
195 if v == nil {
196 v = &model.Content{ID: key, Title: title, Poster: poster}
197 m[key] = v
198 }
199 v.Seconds += seconds
200 v.Records++
201 if p.Exact {
202 // Include every local month touched by the clipped half-open interval.
203 lastMonth := int(b.Add(-time.Nanosecond).In(loc).Month())
204 for month := int(a.In(loc).Month()); month <= lastMonth; month++ {
205 addContentMonth(v, month)
206 }
207 } else {
208 addContentMonth(v, int(p.ObservedAt.In(loc).Month()))
209 }
210 gs := map[string]string{}
211 for _, g := range item.Genres {
212 name := normalizeName(g)
213 key := strings.ToLower(name)
214 if previous, ok := gs[key]; !ok || name < previous {
215 gs[key] = name
216 }
217 }
218 if len(gs) == 0 {
219 gs["unknown"] = "Unknown"
220 }
221 for _, g := range gs {
222 add(genres, g, seconds/float64(len(gs)))
223 }
224 add(devices, p.Device, seconds)
225 add(clients, p.Client, seconds)
226 if item.Type == "Movie" {
227 if item.Runtime <= 0 || p.PositionStart == nil || !p.Exact || !p.Start.Equal(a) || !p.End.Equal(b) {
228 positionUnknown[item.ID] = true
229 completionKnown = false
230 } else {
231 positions[item.ID] = append(positions[item.ID], mediaSpan{math.Min(*p.PositionStart, item.Runtime), math.Min(*p.PositionEnd, item.Runtime)})
232 }
233 }
234 if p.Exact {
235 intervals = append(intervals, span{a, b})
236 allocate(&r, a, b, loc, days)
237 } else {
238 t := p.ObservedAt.In(loc)
239 r.Hourly[t.Hour()]++
240 r.Weekdays[t.Weekday()]++
241 r.Monthly[int(t.Month())-1]++
242 days[t.Format("2006-01-02")] = true
243 }
244 }
245 // Mixed precision uses end-record counts for all distributions rather than mixing units.
246 if !exact {
247 r.Hourly = [24]float64{}
248 r.Weekdays = [7]float64{}
249 r.Monthly = [12]float64{}
250 days = map[string]bool{}
251 for _, p := range s.Playback {
252 i, ok := items[p.ItemID]
253 if !ok || p.UserID != u.ID || p.Seconds < c.MinimumSeconds || (i.Type != "Movie" && i.Type != "Episode") {
254 continue
255 }
256 t := p.ObservedAt
257 if p.Exact {
258 if !p.End.After(begin) || !p.Start.Before(end) {
259 continue
260 }
261 t = p.End.Add(-time.Nanosecond)
262 if !t.Before(end) {
263 t = end.Add(-time.Nanosecond)
264 }
265 }
266 if t.Before(begin) || !t.Before(end) {
267 continue
268 }
269 t = t.In(loc)
270 r.Hourly[t.Hour()]++
271 r.Weekdays[t.Weekday()]++
272 r.Monthly[int(t.Month())-1]++
273 days[t.Format("2006-01-02")] = true
274 }
275 r.DistributionMetric = "activity record ends (counts)"
276 r.Warnings = append(r.Warnings, "Aggregate history is assigned to the record end year; exact yearly watch time, play counts, and timing cannot be recovered.")
277 }
278 r.UniqueTitles = len(unique)
279 r.ActiveDays = len(days)
280 r.Movies = content(movies)
281 r.Shows = content(shows)
282 r.Genres = named(genres)
283 r.Devices = named(devices)
284 r.Clients = named(clients)
285 r.Platforms = platforms(r.Movies, r.Shows, items)
286 for i := range r.Movies {
287 v := &r.Movies[i]
288 if positionUnknown[v.ID] {
289 continue
290 }
291 ranges := positions[v.ID]
292 if len(ranges) == 0 {
293 continue
294 }
295 sort.Slice(ranges, func(i, j int) bool { return ranges[i].start < ranges[j].start })
296 covered, finish := 0.0, 0.0
297 for _, p := range ranges {
298 if p.end > finish {
299 covered += p.end - math.Max(p.start, finish)
300 finish = p.end
301 }
302 }
303 pct := math.Min(100, 100*covered/items[v.ID].Runtime)
304 v.Completion = &pct
305 if pct < c.Completion {
306 r.Abandoned = append(r.Abandoned, *v)
307 }
308 }
309 if exact {
310 r.Marathon = marathon(intervals, c.MarathonGap)
311 }
312 r.Availability["exactWatchTime"] = model.Availability{Available: exact, Reason: reason(exact, "Jellystat end-attributed totals are estimates of selected-year viewing.")}
313 r.Availability["marathon"] = model.Availability{Available: exact && r.Records > 0, Reason: reason(exact && r.Records > 0, "Exact uninterrupted intervals are required.")}
314 r.Availability["completion"] = model.Availability{Available: completionKnown, Reason: reason(completionKnown, "Some movies lack runtime or actual media-position ranges; they are omitted from abandonment results.")}
315 if deleted > 0 {
316 r.Warnings = append(r.Warnings, fmt.Sprintf("%d records excluded because media metadata was deleted or unavailable.", deleted))
317 }
318 if aggregate > 0 {
319 r.Warnings = append(r.Warnings, fmt.Sprintf("%d aggregate activity records; records may combine resumed viewing.", aggregate))
320 }
321 r.Personality = personality(r, exact)
322 r.Availability["personality"] = model.Availability{Available: r.Personality != nil, Reason: reason(r.Personality != nil, "At least 10 exact records and 5 watched hours are required.")}
323 out = append(out, r)
324 }
325 rank(out)
326 return out, nil
327}
328
329// addContentMonth deduplicates resumed movies and episodes of the same series.
330func addContentMonth(v *model.Content, month int) {
331 for _, existing := range v.Months {
332 if existing == month {
333 return
334 }
335 }
336 v.Months = append(v.Months, month)
337}
338
339func reason(ok bool, s string) string {
340 if ok {
341 return ""
342 }
343 return s
344}
345
346// allocate walks real UTC minute boundaries, preserving repeated/skipped DST hours.
347func allocate(r *model.Review, a, b time.Time, loc *time.Location, days map[string]bool) {
348 for a.Before(b) {
349 next := a.Truncate(time.Minute).Add(time.Minute)
350 if next.After(b) {
351 next = b
352 }
353 n := next.Sub(a).Minutes()
354 t := a.In(loc)
355 r.Hourly[t.Hour()] += n
356 r.Weekdays[t.Weekday()] += n
357 r.Monthly[int(t.Month())-1] += n
358 days[t.Format("2006-01-02")] = true
359 a = next
360 }
361}
362func marathon(v []span, gap time.Duration) *model.Marathon {
363 if len(v) == 0 {
364 return nil
365 }
366 sort.Slice(v, func(i, j int) bool {
367 if v[i].start.Equal(v[j].start) {
368 return v[i].end.Before(v[j].end)
369 }
370 return v[i].start.Before(v[j].start)
371 })
372 var best *model.Marathon
373 var run model.Marathon
374 save := func() {
375 run.Elapsed = run.End.Sub(run.Start).Seconds()
376 if best == nil || run.Watched > best.Watched || (run.Watched == best.Watched && run.Start.Before(best.Start)) {
377 copy := run
378 best = ©
379 }
380 }
381 for _, s := range v {
382 if run.Records == 0 || s.start.After(run.End.Add(gap)) {
383 if run.Records > 0 {
384 save()
385 }
386 run = model.Marathon{Start: s.start, End: s.end, Watched: s.end.Sub(s.start).Seconds(), Records: 1}
387 continue
388 }
389 run.Records++
390 if s.end.After(run.End) {
391 a := s.start
392 if a.Before(run.End) {
393 a = run.End
394 }
395 run.Watched += s.end.Sub(a).Seconds()
396 run.End = s.end
397 }
398 }
399 save()
400 return best
401}
402func rank(rs []model.Review) {
403 eligible := []int{}
404 total := 0.0
405 for i, r := range rs {
406 if r.Seconds >= 3600 {
407 eligible = append(eligible, i)
408 total += r.Seconds
409 }
410 }
411 for _, i := range eligible {
412 higher, lower := 0, 0
413 for _, j := range eligible {
414 if rs[j].Seconds > rs[i].Seconds {
415 higher++
416 }
417 if rs[j].Seconds < rs[i].Seconds {
418 lower++
419 }
420 }
421 pct := 0.0
422 if len(eligible) > 1 {
423 pct = 100 * float64(lower) / float64(len(eligible)-1)
424 }
425 rs[i].Ranking = &model.Ranking{Rank: higher + 1, Cohort: len(eligible), Percentile: pct, Average: total / float64(len(eligible))}
426 }
427 for i := range rs {
428 rs[i].Availability["ranking"] = model.Availability{Available: rs[i].Ranking != nil, Reason: reason(rs[i].Ranking != nil, "At least one watched hour is required for cohort eligibility.")}
429 }
430}