arnaucube
arnaucube/configsPublic Self-hosted repository
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 groups comma-separated titles 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: ", section.label)
213				heading = true
214			} else {
215				out.WriteString(", ")
216			}
217			out.WriteString(title.Name)
218			if title.Users > 1 {
219				fmt.Fprintf(&out, " (%d %s)", title.Users, labels.Users)
220			}
221		}
222		if heading {
223			out.WriteByte('\n')
224		}
225	}
226	return strings.TrimRight(out.String(), "\n")
227}