jellyfin-tools/jellyfin-news/jellystat.go
jellyfin-news/jellystat.goRaw
1package main
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "fmt"
8 "net/http"
9 "net/url"
10 "sort"
11 "strconv"
12 "strings"
13 "time"
14)
15
16type JellystatClient struct {
17 baseURL, apiKey string
18 http *http.Client
19}
20
21// NewJellystat validates the optional playback-history connection.
22func NewJellystat(baseURL, apiKey string) (*JellystatClient, error) {
23 u, err := url.Parse(strings.TrimRight(strings.TrimSpace(baseURL), "/"))
24 if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") || u.User != nil || u.RawQuery != "" || u.Fragment != "" {
25 return nil, fmt.Errorf("invalid JELLYSTAT_URL")
26 }
27 if strings.TrimSpace(apiKey) == "" {
28 return nil, fmt.Errorf("JELLYSTAT_API_KEY is required when JELLYSTAT_URL is set")
29 }
30 return &JellystatClient{u.String(), strings.TrimSpace(apiKey), &http.Client{Timeout: 30 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}}, nil
31}
32
33type playback struct {
34 UserID string `json:"UserId"`
35 ItemID string `json:"NowPlayingItemId"`
36 Date string `json:"ActivityDateInserted"`
37 SeasonNumber *int `json:"SeasonNumber"`
38 Results []playback `json:"results"`
39}
40
41type WatchedTitle struct {
42 ID, Type, Name string
43 Users int
44 Seasons []int
45}
46
47// request keeps upstream bodies and connection details out of errors and logs.
48func (c *JellystatClient) request(ctx context.Context, path string, body []byte, dst any) error {
49 method := http.MethodGet
50 if body != nil {
51 method = http.MethodPost
52 }
53 req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, bytes.NewReader(body))
54 if err != nil {
55 return fmt.Errorf("create Jellystat request")
56 }
57 req.Header.Set("x-api-token", c.apiKey)
58 if body != nil {
59 req.Header.Set("Content-Type", "application/json")
60 }
61 resp, err := c.http.Do(req)
62 if err != nil {
63 return fmt.Errorf("Jellystat request failed")
64 }
65 defer resp.Body.Close()
66 if resp.StatusCode != http.StatusOK {
67 return fmt.Errorf("Jellystat returned HTTP %d", resp.StatusCode)
68 }
69 if err := json.NewDecoder(resp.Body).Decode(dst); err != nil {
70 return fmt.Errorf("invalid Jellystat response")
71 }
72 return nil
73}
74
75// WatchedBetween checks individual plays, because date filters on getHistory apply
76// to the latest play in each group and can hide earlier repeat viewings.
77func (c *JellystatClient) WatchedBetween(ctx context.Context, start, end time.Time) ([]WatchedTitle, error) {
78 users := map[string]map[string]bool{}
79 seasons := map[string]map[int]bool{}
80 fallback := map[string][]playback{}
81 for page := 1; ; page++ {
82 var response struct {
83 Pages int `json:"pages"`
84 Results []playback `json:"results"`
85 }
86 params := url.Values{"page": {strconv.Itoa(page)}, "size": {"200"}, "sort": {"ActivityDateInserted"}, "desc": {"true"}}
87 if err := c.request(ctx, "/api/getHistory?"+params.Encode(), nil, &response); err != nil {
88 return nil, err
89 }
90 if response.Results == nil {
91 return nil, fmt.Errorf("Jellystat history is missing results")
92 }
93 for _, group := range response.Results {
94 plays := group.Results
95 if len(plays) == 0 {
96 // Some Jellystat versions omit nested movie plays. Fetch the
97 // ungrouped item history instead of treating the latest play as complete.
98 if group.ItemID == "" {
99 return nil, fmt.Errorf("Jellystat playback is missing item ID")
100 }
101 var ok bool
102 plays, ok = fallback[group.ItemID]
103 if !ok {
104 var err error
105 plays, err = c.itemHistory(ctx, group.ItemID, start, end)
106 if err != nil {
107 return nil, err
108 }
109 fallback[group.ItemID] = plays
110 }
111 }
112 for _, play := range plays {
113 date, err := time.Parse(time.RFC3339Nano, play.Date)
114 if err != nil {
115 return nil, fmt.Errorf("invalid Jellystat playback timestamp")
116 }
117 if date.Before(start) || !date.Before(end) {
118 continue
119 }
120 id, user := play.ItemID, play.UserID
121 if id == "" {
122 id = group.ItemID
123 }
124 if user == "" {
125 user = group.UserID
126 }
127 if id == "" || user == "" {
128 return nil, fmt.Errorf("Jellystat playback is missing item or user ID")
129 }
130 if users[id] == nil {
131 users[id] = map[string]bool{}
132 }
133 users[id][user] = true
134 if play.SeasonNumber != nil {
135 if seasons[id] == nil {
136 seasons[id] = map[int]bool{}
137 }
138 seasons[id][*play.SeasonNumber] = true
139 }
140 }
141 }
142 if page >= response.Pages {
143 break
144 }
145 if len(response.Results) == 0 {
146 return nil, fmt.Errorf("incomplete Jellystat history pagination")
147 }
148 }
149 var titles []WatchedTitle
150 for id, viewers := range users {
151 body, _ := json.Marshal(map[string]string{"Id": id})
152 var details []struct{ Name, Type string }
153 if err := c.request(ctx, "/api/getItemDetails", body, &details); err != nil {
154 return nil, err
155 }
156 if len(details) == 0 {
157 return nil, fmt.Errorf("Jellystat item metadata is missing")
158 }
159 item := details[0]
160 if item.Type != "Movie" && item.Type != "Series" {
161 continue
162 }
163 title := WatchedTitle{ID: id, Type: item.Type, Name: item.Name, Users: len(viewers)}
164 if item.Type == "Series" {
165 for season := range seasons[id] {
166 title.Seasons = append(title.Seasons, season)
167 }
168 sort.Ints(title.Seasons)
169 }
170 titles = append(titles, title)
171 }
172 return titles, nil
173}
174
175// itemHistory retrieves raw plays when a grouped response omits its history.
176func (c *JellystatClient) itemHistory(ctx context.Context, id string, start, end time.Time) ([]playback, error) {
177 body, _ := json.Marshal(map[string]string{"itemid": id})
178 filters, _ := json.Marshal([]map[string]string{{"field": "ActivityDateInserted", "min": start.UTC().Format(time.RFC3339Nano), "max": end.UTC().Format(time.RFC3339Nano)}})
179 var plays []playback
180 for page := 1; ; page++ {
181 params := url.Values{"page": {strconv.Itoa(page)}, "size": {"200"}, "filters": {string(filters)}}
182 var response struct {
183 Pages int `json:"pages"`
184 Results []playback `json:"results"`
185 }
186 if err := c.request(ctx, "/api/getItemHistory?"+params.Encode(), body, &response); err != nil {
187 return nil, err
188 }
189 if response.Results == nil {
190 return nil, fmt.Errorf("Jellystat item history is missing results")
191 }
192 plays = append(plays, response.Results...)
193 if page >= response.Pages {
194 return plays, nil
195 }
196 if len(response.Results) == 0 {
197 return nil, fmt.Errorf("incomplete Jellystat item history pagination")
198 }
199 }
200}
201
202// FormatWatched groups comma-separated titles without disclosing account identities.
203func FormatWatched(start, end time.Time, monthly bool, titles []WatchedTitle, labels Labels) string {
204 seasonPrefix := ""
205 for _, initial := range strings.TrimSpace(labels.Season) {
206 seasonPrefix = string(initial)
207 break
208 }
209 period := start.Format("2006-01-02") + " - " + end.Format("2006-01-02")
210 if monthly {
211 period = start.Format("2006-01")
212 }
213 var out strings.Builder
214 fmt.Fprintf(&out, "%s %s:\n", period, labels.WatchedUpdate)
215 if len(titles) == 0 {
216 return out.String() + "\n" + labels.NoWatchedItems
217 }
218 sorted := append([]WatchedTitle(nil), titles...)
219 sort.Slice(sorted, func(i, j int) bool {
220 a, b := strings.ToLower(sorted[i].Name), strings.ToLower(sorted[j].Name)
221 if a == b {
222 return sorted[i].ID < sorted[j].ID
223 }
224 return a < b
225 })
226 for _, section := range []struct{ kind, label string }{{"Movie", labels.Movies}, {"Series", labels.Shows}} {
227 heading := false
228 for _, title := range sorted {
229 if title.Type != section.kind {
230 continue
231 }
232 if !heading {
233 fmt.Fprintf(&out, "\n%s: ", section.label)
234 heading = true
235 } else {
236 out.WriteString(", ")
237 }
238 out.WriteString(title.Name)
239 if title.Type == "Series" && len(title.Seasons) > 0 {
240 parts := make([]string, 0, len(title.Seasons))
241 for _, season := range title.Seasons {
242 label := fmt.Sprintf("%s%d", seasonPrefix, season)
243 if season == 0 {
244 label = labels.Specials
245 }
246 parts = append(parts, label)
247 }
248 fmt.Fprintf(&out, " (%s)", strings.Join(parts, ", "))
249 }
250 if title.Users > 1 {
251 fmt.Fprintf(&out, " (%d %s)", title.Users, labels.Users)
252 }
253 }
254 if heading {
255 out.WriteByte('\n')
256 }
257 }
258 return strings.TrimRight(out.String(), "\n")
259}