arnaucube
arnaucube/jellyfin-toolsPublic Self-hosted repository
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		for i := range r.Movies {
286			v := &r.Movies[i]
287			if positionUnknown[v.ID] {
288				continue
289			}
290			ranges := positions[v.ID]
291			if len(ranges) == 0 {
292				continue
293			}
294			sort.Slice(ranges, func(i, j int) bool { return ranges[i].start < ranges[j].start })
295			covered, finish := 0.0, 0.0
296			for _, p := range ranges {
297				if p.end > finish {
298					covered += p.end - math.Max(p.start, finish)
299					finish = p.end
300				}
301			}
302			pct := math.Min(100, 100*covered/items[v.ID].Runtime)
303			v.Completion = &pct
304			if pct < c.Completion {
305				r.Abandoned = append(r.Abandoned, *v)
306			}
307		}
308		if exact {
309			r.Marathon = marathon(intervals, c.MarathonGap)
310		}
311		r.Availability["exactWatchTime"] = model.Availability{Available: exact, Reason: reason(exact, "Jellystat end-attributed totals are estimates of selected-year viewing.")}
312		r.Availability["marathon"] = model.Availability{Available: exact && r.Records > 0, Reason: reason(exact && r.Records > 0, "Exact uninterrupted intervals are required.")}
313		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.")}
314		if deleted > 0 {
315			r.Warnings = append(r.Warnings, fmt.Sprintf("%d records excluded because media metadata was deleted or unavailable.", deleted))
316		}
317		if aggregate > 0 {
318			r.Warnings = append(r.Warnings, fmt.Sprintf("%d aggregate activity records; records may combine resumed viewing.", aggregate))
319		}
320		r.Personality = personality(r, exact)
321		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.")}
322		out = append(out, r)
323	}
324	rank(out)
325	return out, nil
326}
327
328// addContentMonth deduplicates resumed movies and episodes of the same series.
329func addContentMonth(v *model.Content, month int) {
330	for _, existing := range v.Months {
331		if existing == month {
332			return
333		}
334	}
335	v.Months = append(v.Months, month)
336}
337
338func reason(ok bool, s string) string {
339	if ok {
340		return ""
341	}
342	return s
343}
344
345// allocate walks real UTC minute boundaries, preserving repeated/skipped DST hours.
346func allocate(r *model.Review, a, b time.Time, loc *time.Location, days map[string]bool) {
347	for a.Before(b) {
348		next := a.Truncate(time.Minute).Add(time.Minute)
349		if next.After(b) {
350			next = b
351		}
352		n := next.Sub(a).Minutes()
353		t := a.In(loc)
354		r.Hourly[t.Hour()] += n
355		r.Weekdays[t.Weekday()] += n
356		r.Monthly[int(t.Month())-1] += n
357		days[t.Format("2006-01-02")] = true
358		a = next
359	}
360}
361func marathon(v []span, gap time.Duration) *model.Marathon {
362	if len(v) == 0 {
363		return nil
364	}
365	sort.Slice(v, func(i, j int) bool {
366		if v[i].start.Equal(v[j].start) {
367			return v[i].end.Before(v[j].end)
368		}
369		return v[i].start.Before(v[j].start)
370	})
371	var best *model.Marathon
372	var run model.Marathon
373	save := func() {
374		run.Elapsed = run.End.Sub(run.Start).Seconds()
375		if best == nil || run.Watched > best.Watched || (run.Watched == best.Watched && run.Start.Before(best.Start)) {
376			copy := run
377			best = &copy
378		}
379	}
380	for _, s := range v {
381		if run.Records == 0 || s.start.After(run.End.Add(gap)) {
382			if run.Records > 0 {
383				save()
384			}
385			run = model.Marathon{Start: s.start, End: s.end, Watched: s.end.Sub(s.start).Seconds(), Records: 1}
386			continue
387		}
388		run.Records++
389		if s.end.After(run.End) {
390			a := s.start
391			if a.Before(run.End) {
392				a = run.End
393			}
394			run.Watched += s.end.Sub(a).Seconds()
395			run.End = s.end
396		}
397	}
398	save()
399	return best
400}
401func rank(rs []model.Review) {
402	eligible := []int{}
403	total := 0.0
404	for i, r := range rs {
405		if r.Seconds >= 3600 {
406			eligible = append(eligible, i)
407			total += r.Seconds
408		}
409	}
410	for _, i := range eligible {
411		higher, lower := 0, 0
412		for _, j := range eligible {
413			if rs[j].Seconds > rs[i].Seconds {
414				higher++
415			}
416			if rs[j].Seconds < rs[i].Seconds {
417				lower++
418			}
419		}
420		pct := 0.0
421		if len(eligible) > 1 {
422			pct = 100 * float64(lower) / float64(len(eligible)-1)
423		}
424		rs[i].Ranking = &model.Ranking{Rank: higher + 1, Cohort: len(eligible), Percentile: pct, Average: total / float64(len(eligible))}
425	}
426	for i := range rs {
427		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.")}
428	}
429}