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 Genres []string
136 ImageTags map[string]string
137 ProviderIDs map[string]string
138 }
139 TotalRecordCount int
140 }
141 path := fmt.Sprintf("/Items?Recursive=true&IncludeItemTypes=Movie,Episode,Series&Fields=Genres,ProviderIds&EnableImages=true&StartIndex=%d&Limit=500", start)
142 if err := j.Call(ctx, "GET", path, key, "generator", nil, &page); err != nil {
143 return nil, nil, err
144 }
145 for _, i := range page.Items {
146 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, ImageTag: i.ImageTags["Primary"], ProviderIDs: i.ProviderIDs})
147 }
148 if start+len(page.Items) >= page.TotalRecordCount {
149 return result, items, nil
150 }
151 if len(page.Items) == 0 {
152 return nil, nil, fmt.Errorf("incomplete Jellyfin inventory")
153 }
154 }
155 return nil, nil, fmt.Errorf("Jellyfin inventory exceeds limit")
156}