jellyfin-tools/jellyfin-year-review/internal/artwork/jellyfin.go
jellyfin-year-review/internal/artwork/jellyfin.goRaw
1// Package artwork caches Jellyfin and fallback posters for offline serving.
2package artwork
3
4import (
5 "bytes"
6 "context"
7 "crypto/sha256"
8 "encoding/hex"
9 "fmt"
10 "image/jpeg"
11 "io"
12 "net/http"
13 "net/url"
14 "os"
15 "path/filepath"
16 "sort"
17 "strconv"
18 "time"
19
20 "reelreview/internal/model"
21 "reelreview/internal/source"
22)
23
24const maxImageBytes = 5 << 20
25
26type Client struct {
27 Jellyfin *source.Jellyfin
28 Key, Cache string
29 wait func(context.Context, time.Duration) error
30 tvmaze *tvmazeClient
31}
32
33func New(j *source.Jellyfin, key, cache string) *Client {
34 return &Client{Jellyfin: j, Key: key, Cache: cache, wait: waitRetry, tvmaze: newTVmaze()}
35}
36func digest(b []byte) string { h := sha256.Sum256(b); return hex.EncodeToString(h[:]) }
37
38// validJPEG verifies both allocation bounds and the full image before publication.
39func validJPEG(b []byte) bool {
40 if len(b) > maxImageBytes {
41 return false
42 }
43 cfg, e := jpeg.DecodeConfig(bytes.NewReader(b))
44 if e != nil || cfg.Width < 1 || cfg.Height < 1 || cfg.Width > 4096 || cfg.Height > 4096 {
45 return false
46 }
47 _, e = jpeg.Decode(bytes.NewReader(b))
48 return e == nil
49}
50func waitRetry(ctx context.Context, delay time.Duration) error {
51 timer := time.NewTimer(delay)
52 defer timer.Stop()
53 select {
54 case <-ctx.Done():
55 return ctx.Err()
56 case <-timer.C:
57 return nil
58 }
59}
60func retryDelay(header string, attempt int) time.Duration {
61 delay := time.Duration(attempt+1) * time.Second
62 if n, e := strconv.Atoi(header); e == nil && n > 0 {
63 if n > 3 {
64 n = 3
65 }
66 delay = time.Duration(n) * time.Second
67 } else if date, e := http.ParseTime(header); e == nil {
68 if delta := time.Until(date); delta > 0 {
69 delay = delta
70 }
71 }
72 if delay > 3*time.Second {
73 delay = 3 * time.Second
74 }
75 return delay
76}
77
78// fetch accepts only UUID item paths on the configured Jellyfin server. Image
79// tags invalidate successful cache entries; untagged images are always refreshed.
80func (c *Client) fetch(ctx context.Context, id, tag string) ([]byte, error) {
81 normalized, e := model.ID(id)
82 if e != nil {
83 return nil, fmt.Errorf("invalid Jellyfin artwork ID")
84 }
85 if c.Jellyfin == nil || c.Key == "" {
86 return nil, fmt.Errorf("Jellyfin artwork is not configured")
87 }
88 query := url.Values{"format": {"Jpg"}, "maxWidth": {"500"}, "maxHeight": {"750"}, "quality": {"85"}}
89 if tag != "" {
90 query.Set("tag", tag)
91 }
92 target := c.Jellyfin.URL + "/Items/" + normalized + "/Images/Primary?" + query.Encode()
93 if e = os.MkdirAll(c.Cache, 0700); e != nil {
94 return nil, fmt.Errorf("cannot create artwork cache")
95 }
96 file := filepath.Join(c.Cache, digest([]byte("jellyfin-primary-v1\x00"+target)))
97 if tag != "" {
98 if f, e := os.Open(file); e == nil {
99 b, readErr := io.ReadAll(io.LimitReader(f, maxImageBytes+1))
100 f.Close()
101 if readErr == nil && validJPEG(b) {
102 return b, nil
103 }
104 }
105 }
106 for attempt := 0; attempt < 3; attempt++ {
107 if e = ctx.Err(); e != nil {
108 return nil, e
109 }
110 req, e := http.NewRequestWithContext(ctx, "GET", target, nil)
111 if e != nil {
112 return nil, fmt.Errorf("invalid artwork request")
113 }
114 req.Header.Set("Accept", "image/jpeg")
115 req.Header.Set("Authorization", fmt.Sprintf(`MediaBrowser Client="Reel Review", Device="Review generator", DeviceId="generator", Version="1.0.0", Token="%s"`, url.QueryEscape(c.Key)))
116 res, e := c.Jellyfin.HTTP.Do(req)
117 if e != nil {
118 if ctx.Err() != nil {
119 return nil, ctx.Err()
120 }
121 if attempt == 2 {
122 return nil, fmt.Errorf("Jellyfin artwork unavailable")
123 }
124 } else {
125 b, readErr := io.ReadAll(io.LimitReader(res.Body, maxImageBytes+1))
126 res.Body.Close()
127 if readErr != nil || len(b) > maxImageBytes {
128 return nil, fmt.Errorf("invalid artwork response")
129 }
130 if res.StatusCode == http.StatusOK {
131 if !validJPEG(b) {
132 return nil, fmt.Errorf("invalid JPEG artwork")
133 }
134 if e = c.cache(file, b); e != nil {
135 return nil, e
136 }
137 return b, nil
138 }
139 if res.StatusCode != http.StatusTooManyRequests && res.StatusCode < 500 {
140 return nil, fmt.Errorf("Jellyfin artwork unavailable (%d)", res.StatusCode)
141 }
142 }
143 if attempt < 2 {
144 header := ""
145 if res != nil {
146 header = res.Header.Get("Retry-After")
147 }
148 if e = c.wait(ctx, retryDelay(header, attempt)); e != nil {
149 return nil, e
150 }
151 }
152 }
153 return nil, fmt.Errorf("Jellyfin artwork retries exhausted")
154}
155
156// cache atomically replaces validated artwork/cache records without exposing bodies.
157func (c *Client) cache(path string, b []byte) error {
158 f, e := os.CreateTemp(c.Cache, ".poster-")
159 if e != nil {
160 return fmt.Errorf("cannot write artwork cache")
161 }
162 temp := f.Name()
163 defer os.Remove(temp)
164 _, e = f.Write(b)
165 if e == nil {
166 e = f.Sync()
167 }
168 closeErr := f.Close()
169 if e == nil {
170 e = closeErr
171 }
172 if e == nil {
173 e = os.Rename(temp, path)
174 }
175 if e != nil {
176 return fmt.Errorf("cannot write artwork cache")
177 }
178 return nil
179}
180
181// Resolve downloads only content present in generated reviews, trying Jellyfin
182// before TVmaze for series. Every poster reference points to a local JPEG.
183func (c *Client) Resolve(ctx context.Context, items []model.Item, reviews []model.Review) (map[string][]byte, []string) {
184 assets := map[string][]byte{}
185 warnings := []string{}
186 needed := map[string]bool{}
187 index := map[string]model.Item{}
188 posters := map[string]string{}
189 sources := map[string]string{}
190 series := map[string]model.Item{}
191 for _, item := range items {
192 index[source.ItemKey(item.ID)] = item
193 }
194 for _, r := range reviews {
195 for _, v := range r.Shows {
196 series[v.ID] = model.Item{ID: v.ID, Title: v.Title, Type: "Series"}
197 }
198 for _, list := range [][]model.Content{r.Movies, r.Shows, r.Abandoned} {
199 for _, v := range list {
200 needed[v.ID] = true
201 }
202 }
203 }
204 // Discard any poster references supplied by a canonical input snapshot.
205 assign := func() {
206 for i := range reviews {
207 for _, list := range [][]model.Content{reviews[i].Movies, reviews[i].Shows, reviews[i].Abandoned} {
208 for j := range list {
209 list[j].Poster = posters[list[j].ID]
210 list[j].PosterSource = sources[list[j].ID]
211 }
212 }
213 }
214 }
215 if len(needed) == 0 {
216 assign()
217 return assets, warnings
218 }
219 if c.Jellyfin == nil || c.Key == "" {
220 assign()
221 return assets, []string{"Jellyfin artwork needs JELLYFIN_URL and JELLYFIN_API_KEY during generation; poster placeholders are used."}
222 }
223 ids := make([]string, 0, len(needed))
224 for id := range needed {
225 ids = append(ids, id)
226 }
227 sort.Strings(ids)
228 failed := 0
229 for _, id := range ids {
230 item, ok := index[id]
231 if !ok {
232 item = series[id]
233 }
234 if item.Type != "Movie" && item.Type != "Series" {
235 failed++
236 continue
237 }
238 b, e := c.fetch(ctx, id, item.ImageTag)
239 if e != nil {
240 var sourceURL string
241 b, sourceURL, e = c.fallback(ctx, item)
242 if e != nil {
243 failed++
244 continue
245 }
246 sources[id] = sourceURL
247 }
248 name := digest(b) + ".jpg"
249 assets[name] = b
250 posters[id] = "/art/" + name
251 }
252 assign()
253 if failed > 0 {
254 warnings = append(warnings, fmt.Sprintf("Posters unavailable for %d titles after Jellyfin and TVmaze fallback; poster placeholders are used.", failed))
255 }
256 return assets, warnings
257}