jellyfin-tools/jellyfin-news/jellyfin.go
jellyfin-news/jellyfin.goRaw
1package main
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "io"
8 "net/http"
9 "net/url"
10 "strconv"
11 "strings"
12 "time"
13)
14
15type Client struct {
16 baseURL string
17 apiKey string
18 userID string
19 http *http.Client
20}
21
22func New(baseURL, apiKey, userID string, httpClient *http.Client) (*Client, error) {
23 baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
24 if _, err := url.ParseRequestURI(baseURL); err != nil || !(strings.HasPrefix(baseURL, "http://") || strings.HasPrefix(baseURL, "https://")) {
25 return nil, fmt.Errorf("invalid JELLYFIN_URL %q", baseURL)
26 }
27 if strings.TrimSpace(apiKey) == "" {
28 return nil, fmt.Errorf("JELLYFIN_API_KEY is required")
29 }
30 if httpClient == nil {
31 httpClient = &http.Client{Timeout: 30 * time.Second}
32 }
33 return &Client{baseURL: baseURL, apiKey: apiKey, userID: strings.TrimSpace(userID), http: httpClient}, nil
34}
35
36type response struct {
37 Items []item `json:"Items"`
38 TotalRecordCount int `json:"TotalRecordCount"`
39}
40
41type item struct {
42 ID string `json:"Id"`
43 Type string `json:"Type"`
44 Name string `json:"Name"`
45 SeriesID string `json:"SeriesId"`
46 SeriesName string `json:"SeriesName"`
47 ParentIndexNumber *int `json:"ParentIndexNumber"`
48 IndexNumber *int `json:"IndexNumber"`
49 ProductionYear *int `json:"ProductionYear"`
50 DateCreated string `json:"DateCreated"`
51}
52
53// AddedBetween returns movies and episodes whose Jellyfin DateCreated is in [start, end).
54func (c *Client) AddedBetween(ctx context.Context, start, end time.Time) ([]Item, error) {
55 const pageSize = 200
56 var result []Item
57 for offset := 0; ; offset += pageSize {
58 params := url.Values{
59 "IncludeItemTypes": {"Movie,Episode"},
60 "Recursive": {"true"},
61 "Fields": {"DateCreated"},
62 "SortBy": {"DateCreated"},
63 "SortOrder": {"Descending"},
64 "EnableImages": {"false"},
65 "EnableUserData": {"false"},
66 "EnableTotalRecordCount": {"true"},
67 "StartIndex": {strconv.Itoa(offset)},
68 "Limit": {strconv.Itoa(pageSize)},
69 }
70 if c.userID != "" {
71 params.Set("UserId", c.userID)
72 }
73 req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/Items?"+params.Encode(), nil)
74 if err != nil {
75 return nil, err
76 }
77 req.Header.Set("Authorization", `MediaBrowser Token="`+c.apiKey+`"`)
78 resp, err := c.http.Do(req)
79 if err != nil {
80 return nil, fmt.Errorf("query Jellyfin: %w", err)
81 }
82 var page response
83 decodeErr := json.NewDecoder(resp.Body).Decode(&page)
84 resp.Body.Close()
85 if resp.StatusCode < 200 || resp.StatusCode >= 300 {
86 return nil, fmt.Errorf("Jellyfin returned HTTP %d", resp.StatusCode)
87 }
88 if decodeErr != nil {
89 return nil, fmt.Errorf("decode Jellyfin response: %w", decodeErr)
90 }
91 pastStart := false
92 for _, raw := range page.Items {
93 created, err := time.Parse(time.RFC3339Nano, raw.DateCreated)
94 if err != nil {
95 continue
96 }
97 if created.Before(start) {
98 pastStart = true
99 continue
100 }
101 if !created.Before(end) {
102 continue
103 }
104 result = append(result, Item{
105 ID: raw.ID,
106 Type: raw.Type, Name: raw.Name, SeriesID: raw.SeriesID,
107 SeriesName: raw.SeriesName, SeasonNumber: raw.ParentIndexNumber,
108 EpisodeNumber: raw.IndexNumber, ProductionYear: raw.ProductionYear,
109 })
110 }
111 if pastStart || len(page.Items) < pageSize || (page.TotalRecordCount > 0 && offset+len(page.Items) >= page.TotalRecordCount) {
112 break
113 }
114 }
115 return result, nil
116}
117
118// FindPosterTitle returns an exact library match so a typed title cannot use unrelated artwork.
119func (c *Client) FindPosterTitle(ctx context.Context, kind, name string, year int) (string, error) {
120 params := url.Values{
121 "IncludeItemTypes": {kind},
122 "Recursive": {"true"},
123 "SearchTerm": {name},
124 "Limit": {"50"},
125 "EnableImages": {"false"},
126 "EnableUserData": {"false"},
127 }
128 if c.userID != "" {
129 params.Set("UserId", c.userID)
130 }
131 req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/Items?"+params.Encode(), nil)
132 if err != nil {
133 return "", err
134 }
135 req.Header.Set("Authorization", `MediaBrowser Token="`+c.apiKey+`"`)
136 resp, err := c.http.Do(req)
137 if err != nil {
138 return "", fmt.Errorf("search Jellyfin: %w", err)
139 }
140 defer resp.Body.Close()
141 if resp.StatusCode != http.StatusOK {
142 return "", fmt.Errorf("Jellyfin search returned HTTP %d", resp.StatusCode)
143 }
144 var result response
145 if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&result); err != nil {
146 return "", fmt.Errorf("decode Jellyfin search: %w", err)
147 }
148 for _, item := range result.Items {
149 if item.Type == kind && strings.EqualFold(item.Name, name) && (year == 0 || item.ProductionYear != nil && *item.ProductionYear == year) {
150 return item.ID, nil
151 }
152 }
153 return "", nil
154}