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 ImageTag string `json:"imageTag,omitempty"`
39 ProviderIDs map[string]string `json:"providerIds,omitempty"`
40 Poster string `json:"poster,omitempty"`
41}
42
43// Playback is a finalized activity row, not a progress update. Exact records describe
44// uninterrupted watched intervals; position ranges describe actual media coverage.
45type Playback struct {
46 ID string `json:"id"`
47 UserID string `json:"userId"`
48 ItemID string `json:"itemId"`
49 Start time.Time `json:"start"`
50 End time.Time `json:"end"`
51 ObservedAt time.Time `json:"observedAt"`
52 Seconds float64 `json:"seconds"`
53 Exact bool `json:"exactInterval"`
54 PositionStart *float64 `json:"positionStart,omitempty"`
55 PositionEnd *float64 `json:"positionEnd,omitempty"`
56 Device string `json:"device"`
57 Client string `json:"client"`
58}
59type Snapshot struct {
60 Version int `json:"schemaVersion"`
61 CapturedAt time.Time `json:"capturedAt"`
62 Source string `json:"source"`
63 Coverage string `json:"coverage"`
64 Users []User `json:"users"`
65 Items []Item `json:"items"`
66 Playback []Playback `json:"playback"`
67}
68type Availability struct {
69 Available bool `json:"available"`
70 Reason string `json:"reason,omitempty"`
71}
72type Named struct {
73 Name string `json:"name"`
74 Seconds float64 `json:"seconds"`
75 Records int `json:"records"`
76}
77type Content struct {
78 ID string `json:"id"`
79 Title string `json:"title"`
80 Poster string `json:"poster,omitempty"`
81 PosterSource string `json:"posterSource,omitempty"`
82 Seconds float64 `json:"seconds"`
83 Records int `json:"records"`
84 Months []int `json:"months,omitempty"`
85 Completion *float64 `json:"completionPercent,omitempty"`
86}
87type Marathon struct {
88 Start time.Time `json:"start"`
89 End time.Time `json:"end"`
90 Watched float64 `json:"watchedSeconds"`
91 Elapsed float64 `json:"elapsedSeconds"`
92 Records int `json:"records"`
93}
94type Personality struct {
95 Name string `json:"name"`
96 Explanation string `json:"explanation"`
97}
98type Ranking struct {
99 Rank int `json:"rank"`
100 Cohort int `json:"cohort"`
101 Percentile float64 `json:"percentile"`
102 Average float64 `json:"averageSeconds"`
103}
104type Rules struct {
105 MinimumSeconds float64 `json:"minimumSeconds"`
106 CompletionPercent float64 `json:"completionPercent"`
107 MarathonGapSeconds float64 `json:"marathonGapSeconds"`
108}
109type Review struct {
110 Rules Rules `json:"rules"`
111 Version int `json:"schemaVersion"`
112 User User `json:"user"`
113 Year int `json:"year"`
114 Timezone string `json:"timezone"`
115 GeneratedAt time.Time `json:"generatedAt"`
116 Source string `json:"source"`
117 Coverage string `json:"coverage"`
118 Availability map[string]Availability `json:"availability"`
119 Seconds float64 `json:"seconds"`
120 MovieSeconds float64 `json:"movieSeconds"`
121 TVSeconds float64 `json:"tvSeconds"`
122 Records int `json:"records"`
123 UniqueTitles int `json:"uniqueTitles"`
124 ActiveDays int `json:"activeDays"`
125 Movies []Content `json:"movies"`
126 Shows []Content `json:"shows"`
127 Abandoned []Content `json:"abandoned"`
128 Genres []Named `json:"genres"`
129 Devices []Named `json:"devices"`
130 Clients []Named `json:"clients"`
131 Hourly [24]float64 `json:"hourly"`
132 Weekdays [7]float64 `json:"weekdays"`
133 Monthly [12]float64 `json:"monthly"`
134 DistributionMetric string `json:"distributionMetric"`
135 Marathon *Marathon `json:"marathon"`
136 Personality *Personality `json:"personality"`
137 Ranking *Ranking `json:"ranking"`
138 Warnings []string `json:"warnings"`
139}
140type Manifest struct {
141 Version int `json:"schemaVersion"`
142 Users map[string][]int `json:"users"`
143}
144
145// Validate rejects incompatible, corrupted or incorrectly addressed documents.
146func (r Review) Validate(id string, year int) error {
147 if r.Version != Version || r.User.ID != id || r.Year != year || r.Seconds < 0 || r.Availability == nil {
148 return fmt.Errorf("invalid or unsupported review")
149 }
150 if _, err := time.LoadLocation(r.Timezone); err != nil {
151 return fmt.Errorf("invalid review timezone")
152 }
153 return nil
154}