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}