arnaucube
arnaucube/jellyfin-toolsPublic Self-hosted repository
jellyfin-tools/jellyfin-year-review/internal/artwork/tvmaze.go
jellyfin-year-review/internal/artwork/tvmaze.goRaw
  1package artwork
  2
  3import (
  4	"bytes"
  5	"context"
  6	"encoding/json"
  7	"fmt"
  8	"image"
  9	"image/jpeg"
 10	_ "image/png"
 11	"io"
 12	"net/http"
 13	"net/url"
 14	"os"
 15	"path/filepath"
 16	"strconv"
 17	"strings"
 18	"time"
 19	"unicode"
 20
 21	"reelreview/internal/model"
 22)
 23
 24type tvmazeClient struct {
 25	http     *http.Client
 26	nextCall time.Time
 27	wait     func(context.Context, time.Duration) error
 28}
 29
 30type tvmazeShow struct {
 31	ID        int
 32	Name      string
 33	Premiered string
 34	Image     *struct{ Medium, Original string }
 35}
 36
 37type tvmazePoster struct {
 38	ShowID int    `json:"showId"`
 39	JPEG   []byte `json:"jpeg"`
 40}
 41
 42func newTVmaze() *tvmazeClient {
 43	t := &tvmazeClient{wait: waitRetry}
 44	t.http = &http.Client{Timeout: 12 * time.Second, CheckRedirect: func(req *http.Request, via []*http.Request) error {
 45		if len(via) >= 3 || req.URL.Scheme != "https" || req.URL.User != nil || req.URL.Host != via[0].URL.Host {
 46			return http.ErrUseLastResponse
 47		}
 48		if req.URL.Host == "api.tvmaze.com" {
 49			if e := t.pace(req.Context()); e != nil {
 50				return e
 51			}
 52		}
 53		return nil
 54	}}
 55	return t
 56}
 57
 58// pace includes lookup redirects in the API request rate limit.
 59func (t *tvmazeClient) pace(ctx context.Context) error {
 60	if delay := time.Until(t.nextCall); delay > 0 {
 61		if e := t.wait(ctx, delay); e != nil {
 62			return e
 63		}
 64	}
 65	t.nextCall = time.Now().Add(500 * time.Millisecond)
 66	return nil
 67}
 68
 69// get uses a separate, credential-free client and only TVmaze's API/image hosts.
 70func (t *tvmazeClient) get(ctx context.Context, target string, limit int64) ([]byte, error) {
 71	if e := ctx.Err(); e != nil {
 72		return nil, e
 73	}
 74	u, e := url.Parse(target)
 75	if e != nil || u.Scheme != "https" || u.User != nil || u.Fragment != "" || (u.Host != "api.tvmaze.com" && u.Host != "static.tvmaze.com") {
 76		return nil, fmt.Errorf("invalid TVmaze URL")
 77	}
 78	for attempt := 0; attempt < 3; attempt++ {
 79		if e := ctx.Err(); e != nil {
 80			return nil, e
 81		}
 82		if u.Host == "api.tvmaze.com" {
 83			if e = t.pace(ctx); e != nil {
 84				return nil, e
 85			}
 86		}
 87		req, e := http.NewRequestWithContext(ctx, "GET", target, nil)
 88		if e != nil {
 89			return nil, fmt.Errorf("invalid TVmaze request")
 90		}
 91		req.Header.Set("User-Agent", "Jellyfin-Year-Review/1.0")
 92		res, e := t.http.Do(req)
 93		if e != nil {
 94			if ctx.Err() != nil {
 95				return nil, ctx.Err()
 96			}
 97			return nil, fmt.Errorf("TVmaze unavailable")
 98		}
 99		body, readErr := io.ReadAll(io.LimitReader(res.Body, limit+1))
100		res.Body.Close()
101		if readErr != nil || int64(len(body)) > limit {
102			return nil, fmt.Errorf("invalid TVmaze response")
103		}
104		if res.StatusCode == http.StatusOK {
105			return body, nil
106		}
107		if (res.StatusCode != http.StatusTooManyRequests && res.StatusCode < 500) || attempt == 2 {
108			return nil, fmt.Errorf("TVmaze unavailable (%d)", res.StatusCode)
109		}
110		if e = t.wait(ctx, retryDelay(res.Header.Get("Retry-After"), attempt)); e != nil {
111			return nil, e
112		}
113	}
114	return nil, fmt.Errorf("TVmaze retries exhausted")
115}
116
117// titleKey tolerates punctuation/case differences without accepting fuzzy matches.
118func titleKey(title string) string {
119	return strings.Map(func(r rune) rune {
120		if unicode.IsLetter(r) || unicode.IsDigit(r) {
121			return unicode.ToLower(r)
122		}
123		return -1
124	}, title)
125}
126
127// match prefers provider IDs, then an unambiguous title/alias and premiere year.
128// TVmaze contains television series, so movies are deliberately never matched.
129func (t *tvmazeClient) match(ctx context.Context, item model.Item) (tvmazeShow, error) {
130	for _, provider := range []string{"Tvdb", "Imdb"} {
131		for name, id := range item.ProviderIDs {
132			if !strings.EqualFold(name, provider) || id == "" {
133				continue
134			}
135			parameter := "imdb"
136			if provider == "Tvdb" {
137				parameter = "thetvdb"
138			}
139			body, e := t.get(ctx, "https://api.tvmaze.com/lookup/shows?"+parameter+"="+url.QueryEscape(id), 1<<20)
140			var show tvmazeShow
141			if e == nil && json.Unmarshal(body, &show) == nil && show.ID > 0 && show.Image != nil {
142				return show, nil
143			}
144		}
145	}
146	body, e := t.get(ctx, "https://api.tvmaze.com/search/shows?q="+url.QueryEscape(item.Title), 1<<20)
147	if e != nil {
148		return tvmazeShow{}, e
149	}
150	var hits []struct{ Show tvmazeShow }
151	if json.Unmarshal(body, &hits) != nil || len(hits) > 100 {
152		return tvmazeShow{}, fmt.Errorf("invalid TVmaze search response")
153	}
154	eligible := []tvmazeShow{}
155	exact := []tvmazeShow{}
156	key := titleKey(item.Title)
157	for _, hit := range hits {
158		show := hit.Show
159		if show.ID < 1 || show.Image == nil || (item.Year != 0 && !strings.HasPrefix(show.Premiered, strconv.Itoa(item.Year)+"-")) {
160			continue
161		}
162		eligible = append(eligible, show)
163		if titleKey(show.Name) == key {
164			exact = append(exact, show)
165		}
166	}
167	if len(exact) == 1 {
168		return exact[0], nil
169	}
170	if len(exact) > 1 {
171		return tvmazeShow{}, fmt.Errorf("ambiguous TVmaze title")
172	}
173	// Check a few alternate/localized titles rather than accepting a fuzzy hit.
174	aliases := []tvmazeShow{}
175	for i, show := range eligible {
176		if i == 3 {
177			break
178		}
179		body, e := t.get(ctx, fmt.Sprintf("https://api.tvmaze.com/shows/%d/akas", show.ID), 1<<20)
180		var names []struct{ Name string }
181		if e != nil || json.Unmarshal(body, &names) != nil {
182			continue
183		}
184		for _, name := range names {
185			if titleKey(name.Name) == key {
186				aliases = append(aliases, show)
187				break
188			}
189		}
190	}
191	if len(aliases) == 1 {
192		return aliases[0], nil
193	}
194	return tvmazeShow{}, fmt.Errorf("no unambiguous TVmaze match")
195}
196
197// fallback caches successful TVmaze posters and attribution for offline publishing.
198func (c *Client) fallback(ctx context.Context, item model.Item) ([]byte, string, error) {
199	if item.Type != "Series" || titleKey(item.Title) == "" {
200		return nil, "", fmt.Errorf("TVmaze fallback requires a series title")
201	}
202	identity, _ := json.Marshal(struct {
203		Title     string
204		Year      int
205		Providers map[string]string
206	}{item.Title, item.Year, item.ProviderIDs})
207	file := filepath.Join(c.Cache, digest(append([]byte("tvmaze-poster-v1\x00"), identity...)))
208	if f, e := os.Open(file); e == nil {
209		body, readErr := io.ReadAll(io.LimitReader(f, 8<<20))
210		f.Close()
211		var cached tvmazePoster
212		if readErr == nil && json.Unmarshal(body, &cached) == nil && cached.ShowID > 0 && validJPEG(cached.JPEG) {
213			return cached.JPEG, fmt.Sprintf("https://www.tvmaze.com/shows/%d", cached.ShowID), nil
214		}
215	}
216	show, e := c.tvmaze.match(ctx, item)
217	if e != nil {
218		return nil, "", e
219	}
220	target := show.Image.Medium
221	if target == "" {
222		target = show.Image.Original
223	}
224	imageURL, e := url.Parse(target)
225	if e != nil || imageURL.Host != "static.tvmaze.com" {
226		return nil, "", fmt.Errorf("invalid TVmaze image host")
227	}
228	body, e := c.tvmaze.get(ctx, target, maxImageBytes)
229	if e != nil {
230		return nil, "", e
231	}
232	cfg, _, e := image.DecodeConfig(bytes.NewReader(body))
233	if e != nil || cfg.Width < 1 || cfg.Height < 1 || cfg.Width > 4096 || cfg.Height > 4096 {
234		return nil, "", fmt.Errorf("invalid TVmaze image")
235	}
236	img, _, e := image.Decode(bytes.NewReader(body))
237	if e != nil {
238		return nil, "", fmt.Errorf("invalid TVmaze image")
239	}
240	var jpegBytes bytes.Buffer
241	if jpeg.Encode(&jpegBytes, img, &jpeg.Options{Quality: 85}) != nil || !validJPEG(jpegBytes.Bytes()) {
242		return nil, "", fmt.Errorf("invalid TVmaze image")
243	}
244	cached, _ := json.Marshal(tvmazePoster{ShowID: show.ID, JPEG: jpegBytes.Bytes()})
245	if os.MkdirAll(c.Cache, 0700) != nil || c.cache(file, cached) != nil {
246		return nil, "", fmt.Errorf("cannot write artwork cache")
247	}
248	return jpegBytes.Bytes(), fmt.Sprintf("https://www.tvmaze.com/shows/%d", show.ID), nil
249}