arnaucube
arnaucube/jellyfin-toolsPublic Self-hosted repository
jellyfin-tools/jellyfin-year-review/internal/source/jellyfin.go
jellyfin-year-review/internal/source/jellyfin.goRaw
  1// Package source reads immutable history exports and Jellyfin API metadata.
  2package source
  3
  4import (
  5	"bytes"
  6	"context"
  7	"encoding/json"
  8	"errors"
  9	"fmt"
 10	"io"
 11	"net/http"
 12	"net/url"
 13	"strings"
 14	"time"
 15
 16	"reelreview/internal/model"
 17)
 18
 19var ErrDenied = errors.New("authentication denied")
 20var ErrUnavailable = errors.New("Jellyfin unavailable")
 21
 22type Jellyfin struct {
 23	URL  string
 24	HTTP *http.Client
 25}
 26type Account struct {
 27	ID     string `json:"Id"`
 28	Name   string `json:"Name"`
 29	Policy struct {
 30		Disabled bool `json:"IsDisabled"`
 31	} `json:"Policy"`
 32}
 33
 34func NewJellyfin(raw string) (*Jellyfin, error) {
 35	u, err := url.Parse(raw)
 36	if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" {
 37		return nil, fmt.Errorf("invalid JELLYFIN_URL")
 38	}
 39	return &Jellyfin{URL: strings.TrimRight(raw, "/"), HTTP: &http.Client{Timeout: 12 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}}, nil
 40}
 41
 42// Call never returns server bodies or transport URLs, which may contain sensitive data.
 43func (j *Jellyfin) Call(ctx context.Context, method, path, token, device string, body, out any) error {
 44	var b bytes.Buffer
 45	if body != nil {
 46		if err := json.NewEncoder(&b).Encode(body); err != nil {
 47			return err
 48		}
 49	}
 50	req, err := http.NewRequestWithContext(ctx, method, j.URL+path, &b)
 51	if err != nil {
 52		return ErrUnavailable
 53	}
 54	req.Header.Set("Content-Type", "application/json")
 55	authorization := fmt.Sprintf(`MediaBrowser Client="Reel Review", Device="Review browser", DeviceId="%s", Version="1.0.0"`, url.QueryEscape(device))
 56	if token != "" {
 57		authorization += fmt.Sprintf(`, Token="%s"`, url.QueryEscape(token))
 58	}
 59	req.Header.Set("Authorization", authorization)
 60	res, err := j.HTTP.Do(req)
 61	if err != nil {
 62		return ErrUnavailable
 63	}
 64	defer res.Body.Close()
 65	if res.StatusCode == 401 || res.StatusCode == 403 {
 66		return ErrDenied
 67	}
 68	if res.StatusCode < 200 || res.StatusCode >= 300 {
 69		return ErrUnavailable
 70	}
 71	if out != nil {
 72		if err := json.NewDecoder(io.LimitReader(res.Body, 32<<20)).Decode(out); err != nil {
 73			return ErrUnavailable
 74		}
 75	}
 76	return nil
 77}
 78func (j *Jellyfin) Me(ctx context.Context, token, device string) (Account, error) {
 79	var a Account
 80	err := j.Call(ctx, "GET", "/Users/Me", token, device, nil, &a)
 81	if err != nil {
 82		return a, err
 83	}
 84	id, err := model.ID(a.ID)
 85	if err != nil || a.Policy.Disabled {
 86		return a, ErrDenied
 87	}
 88	a.ID = id
 89	return a, nil
 90}
 91func (j *Jellyfin) Login(ctx context.Context, name, password, device string) (Account, string, error) {
 92	var r struct {
 93		AccessToken string
 94		User        Account
 95	}
 96	err := j.Call(ctx, "POST", "/Users/AuthenticateByName", "", device, map[string]string{"Username": name, "Pw": password}, &r)
 97	if err != nil {
 98		return Account{}, "", err
 99	}
100	if r.AccessToken == "" {
101		return Account{}, "", ErrDenied
102	}
103	a, err := j.Me(ctx, r.AccessToken, device)
104	if err != nil {
105		_ = j.Call(ctx, "POST", "/Sessions/Logout", r.AccessToken, device, nil, nil)
106		return Account{}, "", err
107	}
108	return a, r.AccessToken, nil
109}
110
111// Metadata takes a bounded inventory once; API responses become the generation snapshot.
112func (j *Jellyfin) Metadata(ctx context.Context, key string) ([]model.User, []model.Item, error) {
113	var users []Account
114	if err := j.Call(ctx, "GET", "/Users", key, "generator", nil, &users); err != nil {
115		return nil, nil, err
116	}
117	result := []model.User{}
118	for _, u := range users {
119		id, err := model.ID(u.ID)
120		if err != nil {
121			return nil, nil, err
122		}
123		if !u.Policy.Disabled {
124			result = append(result, model.User{ID: id, Name: u.Name})
125		}
126	}
127	items := []model.Item{}
128	for start := 0; start < 1000000; start += 500 {
129		var page struct {
130			Items []struct {
131				ID                               string `json:"Id"`
132				Name, Type, SeriesID, SeriesName string
133				ProductionYear                   int
134				RunTimeTicks                     int64
135				Studios                          []struct{ Name string }
136				Genres                           []string
137				ImageTags                        map[string]string
138				ProviderIDs                      map[string]string
139			}
140			TotalRecordCount int
141		}
142		path := fmt.Sprintf("/Items?Recursive=true&IncludeItemTypes=Movie,Episode,Series&Fields=Genres,ProviderIds,Studios&EnableImages=true&StartIndex=%d&Limit=500", start)
143		if err := j.Call(ctx, "GET", path, key, "generator", nil, &page); err != nil {
144			return nil, nil, err
145		}
146		for _, i := range page.Items {
147			studios := []string{}
148			for _, studio := range i.Studios {
149				studios = append(studios, studio.Name)
150			}
151			items = append(items, model.Item{ID: i.ID, Title: i.Name, Type: i.Type, SeriesID: i.SeriesID, SeriesTitle: i.SeriesName, Year: i.ProductionYear, Runtime: float64(i.RunTimeTicks) / 1e7, Genres: i.Genres, Studios: studios, ImageTag: i.ImageTags["Primary"], ProviderIDs: i.ProviderIDs})
152		}
153		if start+len(page.Items) >= page.TotalRecordCount {
154			return result, items, nil
155		}
156		if len(page.Items) == 0 {
157			return nil, nil, fmt.Errorf("incomplete Jellyfin inventory")
158		}
159	}
160	return nil, nil, fmt.Errorf("Jellyfin inventory exceeds limit")
161}