arnaucube
arnaucube/configsPublic Self-hosted repository
configs/server-dockers/jellyfin-news/jellyfin.go
server-dockers/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("X-Emby-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("X-Emby-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}