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	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}