jellyfin-tools/jellyfin-year-review/internal/model/model.go
jellyfin-year-review/internal/model/model.goRaw
1// Package model defines the private source snapshot and public-to-owner review v1 contracts.
2package model
3
4import (
5 "encoding/hex"
6 "fmt"
7 "strings"
8 "time"
9)
10
11const Version = 1
12
13// ID converts Jellyfin UUIDs to lowercase, hyphen-free identity keys.
14func ID(s string) (string, error) {
15 s = strings.ToLower(strings.ReplaceAll(s, "-", ""))
16 if len(s) != 32 {
17 return "", fmt.Errorf("invalid Jellyfin UUID")
18 }
19 if _, err := hex.DecodeString(s); err != nil {
20 return "", fmt.Errorf("invalid Jellyfin UUID")
21 }
22 return s, nil
23}
24
25type User struct {
26 ID string `json:"id"`
27 Name string `json:"name"`
28}
29type Item struct {
30 ID string `json:"id"`
31 Title string `json:"title"`
32 Type string `json:"type"`
33 SeriesID string `json:"seriesId,omitempty"`
34 SeriesTitle string `json:"seriesTitle,omitempty"`
35 Year int `json:"year,omitempty"`
36 Runtime float64 `json:"runtimeSeconds,omitempty"`
37 Genres []string `json:"genres"`
38 Studios []string `json:"studios,omitempty"`
39 Platforms []string `json:"platforms,omitempty"`
40 ImageTag string `json:"imageTag,omitempty"`
41 ProviderIDs map[string]string `json:"providerIds,omitempty"`
42 Poster string `json:"poster,omitempty"`
43}
44
45// Playback is a finalized activity row, not a progress update. Exact records describe
46// uninterrupted watched intervals; position ranges describe actual media coverage.
47type Playback struct {
48 ID string `json:"id"`
49 UserID string `json:"userId"`
50 ItemID string `json:"itemId"`
51 Start time.Time `json:"start"`
52 End time.Time `json:"end"`
53 ObservedAt time.Time `json:"observedAt"`
54 Seconds float64 `json:"seconds"`
55 Exact bool `json:"exactInterval"`
56 PositionStart *float64 `json:"positionStart,omitempty"`
57 PositionEnd *float64 `json:"positionEnd,omitempty"`
58 Device string `json:"device"`
59 Client string `json:"client"`
60}
61type Snapshot struct {
62 Version int `json:"schemaVersion"`
63 CapturedAt time.Time `json:"capturedAt"`
64 Source string `json:"source"`
65 Coverage string `json:"coverage"`
66 Users []User `json:"users"`
67 Items []Item `json:"items"`
68 Playback []Playback `json:"playback"`
69}
70type Availability struct {
71 Available bool `json:"available"`
72 Reason string `json:"reason,omitempty"`
73}
74type Named struct {
75 Name string `json:"name"`
76 Seconds float64 `json:"seconds"`
77 Records int `json:"records"`
78}
79
80// Platform counts distinct watched movies and series associated with a service.
81type Platform struct {
82 Name string `json:"name"`
83 Movies int `json:"movies"`
84 Shows int `json:"shows"`
85}
86type Content struct {
87 ID string `json:"id"`
88 Title string `json:"title"`
89 Poster string `json:"poster,omitempty"`
90 PosterSource string `json:"posterSource,omitempty"`
91 Seconds float64 `json:"seconds"`
92 Records int `json:"records"`
93 Months []int `json:"months,omitempty"`
94 Completion *float64 `json:"completionPercent,omitempty"`
95}
96type Marathon struct {
97 Start time.Time `json:"start"`
98 End time.Time `json:"end"`
99 Watched float64 `json:"watchedSeconds"`
100 Elapsed float64 `json:"elapsedSeconds"`
101 Records int `json:"records"`
102}
103type Personality struct {
104 Name string `json:"name"`
105 Explanation string `json:"explanation"`
106}
107type Ranking struct {
108 Rank int `json:"rank"`
109 Cohort int `json:"cohort"`
110 Percentile float64 `json:"percentile"`
111 Average float64 `json:"averageSeconds"`
112}
113type Rules struct {
114 MinimumSeconds float64 `json:"minimumSeconds"`
115 CompletionPercent float64 `json:"completionPercent"`
116 MarathonGapSeconds float64 `json:"marathonGapSeconds"`
117}
118type Review struct {
119 Rules Rules `json:"rules"`
120 Version int `json:"schemaVersion"`
121 User User `json:"user"`
122 Year int `json:"year"`
123 Timezone string `json:"timezone"`
124 GeneratedAt time.Time `json:"generatedAt"`
125 Source string `json:"source"`
126 Coverage string `json:"coverage"`
127 Availability map[string]Availability `json:"availability"`
128 Seconds float64 `json:"seconds"`
129 MovieSeconds float64 `json:"movieSeconds"`
130 TVSeconds float64 `json:"tvSeconds"`
131 Records int `json:"records"`
132 UniqueTitles int `json:"uniqueTitles"`
133 ActiveDays int `json:"activeDays"`
134 Movies []Content `json:"movies"`
135 Shows []Content `json:"shows"`
136 Platforms []Platform `json:"platforms,omitempty"`
137 Abandoned []Content `json:"abandoned"`
138 Genres []Named `json:"genres"`
139 Devices []Named `json:"devices"`
140 Clients []Named `json:"clients"`
141 Hourly [24]float64 `json:"hourly"`
142 Weekdays [7]float64 `json:"weekdays"`
143 Monthly [12]float64 `json:"monthly"`
144 DistributionMetric string `json:"distributionMetric"`
145 Marathon *Marathon `json:"marathon"`
146 Personality *Personality `json:"personality"`
147 Ranking *Ranking `json:"ranking"`
148 Warnings []string `json:"warnings"`
149}
150type Manifest struct {
151 Version int `json:"schemaVersion"`
152 Users map[string][]int `json:"users"`
153}
154
155// Validate rejects incompatible, corrupted or incorrectly addressed documents.
156func (r Review) Validate(id string, year int) error {
157 if r.Version != Version || r.User.ID != id || r.Year != year || r.Seconds < 0 || r.Availability == nil {
158 return fmt.Errorf("invalid or unsupported review")
159 }
160 if _, err := time.LoadLocation(r.Timezone); err != nil {
161 return fmt.Errorf("invalid review timezone")
162 }
163 return nil
164}