configs/server-dockers/jellyfin-news/jellystat.go
server-dockers/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 Results []playback `json:"results"`
38}
39
40type WatchedTitle struct {
41 ID, Type, Name string
42 Users int
43}
44
45// request keeps upstream bodies and connection details out of errors and logs.
46func (c *JellystatClient) request(ctx context.Context, path string, body []byte, dst any) error {
47 method := http.MethodGet
48 if body != nil {
49 method = http.MethodPost
50 }
51 req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, bytes.NewReader(body))
52 if err != nil {
53 return fmt.Errorf("create Jellystat request")
54 }
55 req.Header.Set("x-api-token", c.apiKey)
56 if body != nil {
57 req.Header.Set("Content-Type", "application/json")
58 }
59 resp, err := c.http.Do(req)
60 if err != nil {
61 return fmt.Errorf("Jellystat request failed")
62 }
63 defer resp.Body.Close()
64 if resp.StatusCode != http.StatusOK {
65 return fmt.Errorf("Jellystat returned HTTP %d", resp.StatusCode)
66 }
67 if err := json.NewDecoder(resp.Body).Decode(dst); err != nil {
68 return fmt.Errorf("invalid Jellystat response")
69 }
70 return nil
71}
72
73// WatchedBetween checks individual plays, because date filters on getHistory apply
74// to the latest play in each group and can hide earlier repeat viewings.
75func (c *JellystatClient) WatchedBetween(ctx context.Context, start, end time.Time) ([]WatchedTitle, error) {
76 users := map[string]map[string]bool{}
77 fallback := map[string][]playback{}
78 for page := 1; ; page++ {
79 var response struct {
80 Pages int `json:"pages"`
81 Results []playback `json:"results"`
82 }
83 params := url.Values{"page": {strconv.Itoa(page)}, "size": {"200"}, "sort": {"ActivityDateInserted"}, "desc": {"true"}}
84 if err := c.request(ctx, "/api/getHistory?"+params.Encode(), nil, &response); err != nil {
85 return nil, err
86 }
87 if response.Results == nil {
88 return nil, fmt.Errorf("Jellystat history is missing results")
89 }
90 for _, group := range response.Results {
91 plays := group.Results
92 if len(plays) == 0 {
93 // Some Jellystat versions omit nested movie plays. Fetch the
94 // ungrouped item history instead of treating the latest play as complete.
95 if group.ItemID == "" {
96 return nil, fmt.Errorf("Jellystat playback is missing item ID")
97 }
98 var ok bool
99 plays, ok = fallback[group.ItemID]
100 if !ok {
101 var err error
102 plays, err = c.itemHistory(ctx, group.ItemID, start, end)
103 if err != nil {
104 return nil, err
105 }
106 fallback[group.ItemID] = plays
107 }
108 }
109 for _, play := range plays {
110 date, err := time.Parse(time.RFC3339Nano, play.Date)
111 if err != nil {
112 return nil, fmt.Errorf("invalid Jellystat playback timestamp")
113 }
114 if date.Before(start) || !date.Before(end) {
115 continue
116 }
117 id, user := play.ItemID, play.UserID
118 if id == "" {
119 id = group.ItemID
120 }
121 if user == "" {
122 user = group.UserID
123 }
124 if id == "" || user == "" {
125 return nil, fmt.Errorf("Jellystat playback is missing item or user ID")
126 }
127 if users[id] == nil {
128 users[id] = map[string]bool{}
129 }
130 users[id][user] = true
131 }
132 }
133 if page >= response.Pages {
134 break
135 }
136 if len(response.Results) == 0 {
137 return nil, fmt.Errorf("incomplete Jellystat history pagination")
138 }
139 }
140 var titles []WatchedTitle
141 for id, viewers := range users {
142 body, _ := json.Marshal(map[string]string{"Id": id})
143 var details []struct{ Name, Type string }
144 if err := c.request(ctx, "/api/getItemDetails", body, &details); err != nil {
145 return nil, err
146 }
147 if len(details) == 0 {
148 return nil, fmt.Errorf("Jellystat item metadata is missing")
149 }
150 item := details[0]
151 if item.Type != "Movie" && item.Type != "Series" {
152 continue
153 }
154 titles = append(titles, WatchedTitle{id, item.Type, item.Name, len(viewers)})
155 }
156 return titles, nil
157}
158
159// itemHistory retrieves raw plays when a grouped response omits its history.
160func (c *JellystatClient) itemHistory(ctx context.Context, id string, start, end time.Time) ([]playback, error) {
161 body, _ := json.Marshal(map[string]string{"itemid": id})
162 filters, _ := json.Marshal([]map[string]string{{"field": "ActivityDateInserted", "min": start.UTC().Format(time.RFC3339Nano), "max": end.UTC().Format(time.RFC3339Nano)}})
163 var plays []playback
164 for page := 1; ; page++ {
165 params := url.Values{"page": {strconv.Itoa(page)}, "size": {"200"}, "filters": {string(filters)}}
166 var response struct {
167 Pages int `json:"pages"`
168 Results []playback `json:"results"`
169 }
170 if err := c.request(ctx, "/api/getItemHistory?"+params.Encode(), body, &response); err != nil {
171 return nil, err
172 }
173 if response.Results == nil {
174 return nil, fmt.Errorf("Jellystat item history is missing results")
175 }
176 plays = append(plays, response.Results...)
177 if page >= response.Pages {
178 return plays, nil
179 }
180 if len(response.Results) == 0 {
181 return nil, fmt.Errorf("incomplete Jellystat item history pagination")
182 }
183 }
184}
185
186// FormatWatched lists each title once without disclosing account identities.
187func FormatWatched(start, end time.Time, monthly bool, titles []WatchedTitle, labels Labels) string {
188 period := start.Format("2006-01-02") + " - " + end.Format("2006-01-02")
189 if monthly {
190 period = start.Format("2006-01")
191 }
192 var out strings.Builder
193 fmt.Fprintf(&out, "%s %s:\n", period, labels.WatchedUpdate)
194 if len(titles) == 0 {
195 return out.String() + "\n" + labels.NoWatchedItems
196 }
197 sorted := append([]WatchedTitle(nil), titles...)
198 sort.Slice(sorted, func(i, j int) bool {
199 a, b := strings.ToLower(sorted[i].Name), strings.ToLower(sorted[j].Name)
200 if a == b {
201 return sorted[i].ID < sorted[j].ID
202 }
203 return a < b
204 })
205 for _, section := range []struct{ kind, label string }{{"Movie", labels.Movies}, {"Series", labels.Shows}} {
206 heading := false
207 for _, title := range sorted {
208 if title.Type != section.kind {
209 continue
210 }
211 if !heading {
212 fmt.Fprintf(&out, "\n%s:\n", section.label)
213 heading = true
214 }
215 fmt.Fprintf(&out, "• %s", title.Name)
216 if title.Users > 1 {
217 fmt.Fprintf(&out, " — %d %s", title.Users, labels.Users)
218 }
219 out.WriteByte('\n')
220 }
221 }
222 return strings.TrimRight(out.String(), "\n")
223}