arnaucube
arnaucube/jellyfin-toolsPublic Self-hosted repository
jellyfin-tools/jellyfin-year-review/internal/source/snapshot.go
jellyfin-year-review/internal/source/snapshot.goRaw
  1package source
  2
  3import (
  4	"bufio"
  5	"encoding/json"
  6	"fmt"
  7	"io"
  8	"math"
  9	"os"
 10	"sort"
 11	"strconv"
 12	"strings"
 13	"time"
 14
 15	"reelreview/internal/model"
 16)
 17
 18func ItemKey(s string) string { return strings.ToLower(strings.ReplaceAll(s, "-", "")) }
 19
 20// Load validates a finalized canonical snapshot; inconsistent duplicates fail closed.
 21func Load(path string) (model.Snapshot, error) {
 22	var s model.Snapshot
 23	f, err := os.Open(path)
 24	if err != nil {
 25		return s, fmt.Errorf("cannot open source snapshot")
 26	}
 27	defer f.Close()
 28	if st, e := f.Stat(); e != nil || st.Size() > 256<<20 {
 29		return s, fmt.Errorf("source snapshot exceeds size limit or cannot be read")
 30	}
 31	d := json.NewDecoder(io.LimitReader(f, 256<<20))
 32	d.DisallowUnknownFields()
 33	if err := d.Decode(&s); err != nil {
 34		return s, fmt.Errorf("invalid source snapshot: %w", err)
 35	}
 36	if d.Decode(new(any)) != io.EOF {
 37		return s, fmt.Errorf("trailing or oversized source data")
 38	}
 39	return s, Validate(&s)
 40}
 41func Validate(s *model.Snapshot) error {
 42	if s.Version != 1 || s.CapturedAt.IsZero() || s.Source == "" || s.Coverage == "" {
 43		return fmt.Errorf("snapshot requires v1, capturedAt, source and explicit coverage")
 44	}
 45	users := map[string]bool{}
 46	for i := range s.Users {
 47		u := &s.Users[i]
 48		id, err := model.ID(u.ID)
 49		if err != nil || users[id] {
 50			return fmt.Errorf("invalid or duplicate source user")
 51		}
 52		u.ID = id
 53		users[id] = true
 54	}
 55	items := map[string]bool{}
 56	for i := range s.Items {
 57		v := &s.Items[i]
 58		v.ID = ItemKey(v.ID)
 59		v.SeriesID = ItemKey(v.SeriesID)
 60		if v.ID == "" || items[v.ID] || v.Runtime < 0 || math.IsNaN(v.Runtime) || math.IsInf(v.Runtime, 0) {
 61			return fmt.Errorf("invalid or duplicate media")
 62		}
 63		items[v.ID] = true
 64	}
 65	seen := map[string]string{}
 66	out := []model.Playback{}
 67	for _, p := range s.Playback {
 68		var err error
 69		p.UserID, err = model.ID(p.UserID)
 70		if err != nil {
 71			return fmt.Errorf("invalid playback user")
 72		}
 73		p.ItemID = ItemKey(p.ItemID)
 74		if p.ID == "" || p.ItemID == "" || math.IsNaN(p.Seconds) || math.IsInf(p.Seconds, 0) || p.Seconds <= 0 || p.Seconds > 366*86400 {
 75			return fmt.Errorf("invalid playback duration or identity")
 76		}
 77		if p.Exact {
 78			if !p.End.After(p.Start) || p.Start.IsZero() || math.Abs(p.End.Sub(p.Start).Seconds()-p.Seconds) > 0.01 {
 79				return fmt.Errorf("exact intervals must match watched seconds")
 80			}
 81		} else if p.ObservedAt.IsZero() {
 82			return fmt.Errorf("aggregate activity requires observedAt")
 83		}
 84		if (p.PositionStart == nil) != (p.PositionEnd == nil) {
 85			return fmt.Errorf("both position bounds required")
 86		}
 87		if p.PositionStart != nil && (math.IsNaN(*p.PositionStart) || math.IsInf(*p.PositionStart, 0) || *p.PositionStart < 0 || *p.PositionEnd < *p.PositionStart || math.IsNaN(*p.PositionEnd) || math.IsInf(*p.PositionEnd, 0)) {
 88			return fmt.Errorf("invalid position range")
 89		}
 90		b, _ := json.Marshal(p)
 91		key := p.UserID + ":" + p.ID
 92		if prev, ok := seen[key]; ok {
 93			if prev != string(b) {
 94				return fmt.Errorf("conflicting duplicate playback ID")
 95			}
 96			continue
 97		}
 98		seen[key] = string(b)
 99		out = append(out, p)
100	}
101	s.Playback = out
102	sort.Slice(s.Users, func(i, j int) bool { return s.Users[i].ID < s.Users[j].ID })
103	sort.Slice(s.Items, func(i, j int) bool { return s.Items[i].ID < s.Items[j].ID })
104	sort.Slice(s.Playback, func(i, j int) bool {
105		a, b := s.Playback[i], s.Playback[j]
106		if a.UserID != b.UserID {
107			return a.UserID < b.UserID
108		}
109		return a.ID < b.ID
110	})
111	return nil
112}
113
114// Jellystat reads the raw table JSONL contract, deliberately rejecting grouped API
115// history: grouped durations and resumed records cannot recover exact watch intervals.
116func Jellystat(path string, users []model.User, items []model.Item, captured time.Time, coverage string) (model.Snapshot, error) {
117	s := model.Snapshot{Version: 1, CapturedAt: captured, Source: "Jellystat raw finalized activity", Coverage: coverage, Users: users, Items: items}
118	f, err := os.Open(path)
119	if err != nil {
120		return s, fmt.Errorf("cannot open Jellystat export")
121	}
122	defer f.Close()
123	if st, e := f.Stat(); e != nil || st.Size() > 256<<20 {
124		return s, fmt.Errorf("Jellystat snapshot exceeds size limit or cannot be read")
125	}
126	scan := bufio.NewScanner(io.LimitReader(f, 256<<20))
127	scan.Buffer(make([]byte, 4096), 1<<20)
128	for scan.Scan() {
129		var row struct {
130			ID       string      `json:"Id"`
131			UserID   string      `json:"UserId"`
132			Item     string      `json:"NowPlayingItemId"`
133			Episode  string      `json:"EpisodeId"`
134			Duration json.Number `json:"PlaybackDuration"`
135			At       time.Time   `json:"ActivityDateInserted"`
136			Device   string      `json:"DeviceName"`
137			Client   string      `json:"Client"`
138			Imported bool        `json:"Imported"`
139		}
140		if err := json.Unmarshal(scan.Bytes(), &row); err != nil {
141			return s, fmt.Errorf("invalid Jellystat raw row")
142		}
143		if row.Imported {
144			return s, fmt.Errorf("Playback Reporting imports are not supported; export native Jellystat activity only")
145		}
146		n, err := strconv.ParseFloat(string(row.Duration), 64)
147		if err != nil {
148			return s, fmt.Errorf("invalid Jellystat PlaybackDuration")
149		}
150		if row.Episode != "" {
151			row.Item = row.Episode
152		}
153		s.Playback = append(s.Playback, model.Playback{ID: row.ID, UserID: row.UserID, ItemID: row.Item, ObservedAt: row.At, Seconds: n, Device: row.Device, Client: row.Client})
154	}
155	if scan.Err() != nil {
156		return s, fmt.Errorf("Jellystat export exceeds row limit or cannot be read")
157	}
158	return s, Validate(&s)
159}