arnaucube
arnaucube/jellyfin-toolsPublic Self-hosted repository
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}