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		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 = &copy
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}