orchard
mirrorEvery site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.
blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics
1package main
2
3import (
4 "context"
5 "fmt"
6 "math"
7 "sort"
8 "strings"
9 "time"
10)
11
12// What is being watched, where it is streaming, and whether it is any good.
13//
14// JustWatch's own front end talks to this GraphQL endpoint and it answers
15// without a key, which is the only reason this panel exists: TMDB and Trakt
16// both want one, and Rotten Tomatoes has had no public API since Fandango shut
17// it down. This one hands over the tomatometer and the IMDb score together.
18//
19// It is unofficial, so it is the second most likely thing here to break after
20// the pollen source, and its failure costs this panel and nothing else.
21const (
22 justwatchURL = "https://apis.justwatch.com/graphql"
23 streamingEvery = 3 * time.Hour
24 streamingShown = 6
25
26 // Asked for wider than shown, because titles with no score and titles on
27 // nothing Isaac subscribes to both get dropped.
28 streamingAsked = 24
29)
30
31// popularQuery is JustWatch's own popularTitles ranking, which is driven by
32// what their users are actually doing. That is a better read on "making waves"
33// than a release date is: a show in its fourth season is new news even though
34// its release year is four years old.
35const popularQuery = `query Popular($country: Country!, $first: Int!) {
36 popularTitles(country: $country, first: $first, filter: {objectTypes: [MOVIE, SHOW]}) {
37 edges { node {
38 objectType
39 content(country: $country, language: "en") {
40 title
41 originalReleaseYear
42 scoring { imdbScore tomatoMeter }
43 externalIds { imdbId }
44 }
45 offers(country: $country, platform: WEB) {
46 monetizationType
47 package { clearName }
48 }
49 } }
50 }
51}`
52
53type Title struct {
54 Name string `json:"name"`
55 URL string `json:"url"`
56 Kind string `json:"kind"`
57 Year int `json:"year"`
58 IMDb string `json:"imdb"`
59 Tomato string `json:"tomato"`
60 Provider string `json:"provider"`
61
62 // Each score graded on its own, so a good number reads as good without
63 // having to be compared against the other five rows.
64 IMDbState string `json:"imdb_state"`
65 TomatoState string `json:"tomato_state"`
66
67 // The two scores averaged onto one bar, since the pair of them is what
68 // anyone actually reads and a bar is quicker to scan down a column than two
69 // numbers are. IMDb is out of ten so it is multiplied up first. Source says
70 // which numbers went into it, because an average of one is not the same
71 // claim as an average of two.
72 Score int `json:"score"`
73 ScoreBand string `json:"score_band"`
74 ScoreFrom string `json:"score_from"`
75}
76
77type justwatchPayload struct {
78 Data struct {
79 PopularTitles struct {
80 Edges []struct {
81 Node struct {
82 ObjectType string `json:"objectType"`
83 Content struct {
84 Title string `json:"title"`
85 Year int `json:"originalReleaseYear"`
86 Scoring struct {
87 IMDb float64 `json:"imdbScore"`
88 Tomato int `json:"tomatoMeter"`
89 } `json:"scoring"`
90 ExternalIDs struct {
91 IMDbID string `json:"imdbId"`
92 } `json:"externalIds"`
93 } `json:"content"`
94 Offers []struct {
95 MonetizationType string `json:"monetizationType"`
96 Package struct {
97 ClearName string `json:"clearName"`
98 } `json:"package"`
99 } `json:"offers"`
100 } `json:"node"`
101 } `json:"edges"`
102 } `json:"popularTitles"`
103 } `json:"data"`
104 Errors []struct {
105 Message string `json:"message"`
106 } `json:"errors"`
107}
108
109func fetchStreaming(ctx context.Context, g *Guard) ([]Title, error) {
110 body := map[string]any{
111 "query": popularQuery,
112 "variables": map[string]any{
113 "country": "US",
114 "first": streamingAsked,
115 },
116 }
117
118 var payload justwatchPayload
119 if err := postJSON(ctx, g, "justwatch", justwatchURL, body, &payload); err != nil {
120 return nil, err
121 }
122 if len(payload.Errors) > 0 {
123 return nil, fmt.Errorf("justwatch: %s", payload.Errors[0].Message)
124 }
125
126 out := make([]Title, 0, streamingShown)
127 for _, e := range payload.Data.PopularTitles.Edges {
128 if len(out) == streamingShown {
129 break
130 }
131 n := e.Node
132 c := n.Content
133 if strings.TrimSpace(c.Title) == "" || c.Scoring.IMDb == 0 {
134 continue
135 }
136
137 names := make([]string, 0, len(n.Offers))
138 for _, o := range n.Offers {
139 // A subscription only. Isaac is not looking for what he can rent.
140 if o.MonetizationType == "FLATRATE" {
141 names = append(names, o.Package.ClearName)
142 }
143 }
144 provider := pickProvider(names)
145 if provider == "" {
146 continue
147 }
148
149 t := Title{
150 Name: c.Title,
151 Kind: "FILM",
152 Year: c.Year,
153 IMDb: fmt.Sprintf("%.1f", c.Scoring.IMDb),
154 Provider: provider,
155 }
156 if n.ObjectType == "SHOW" {
157 t.Kind = "TV"
158 }
159 t.URL = imdbURL(c.ExternalIDs.IMDbID)
160 t.IMDbState = gradeIMDb(c.Scoring.IMDb)
161 if c.Scoring.Tomato > 0 {
162 t.Tomato = fmt.Sprintf("%d%%", c.Scoring.Tomato)
163 t.TomatoState = gradeTomato(c.Scoring.Tomato)
164 }
165 t.Score, t.ScoreFrom = combineScores(c.Scoring.IMDb, c.Scoring.Tomato)
166 t.ScoreBand = gradeScore(t.Score)
167
168 out = append(out, t)
169 }
170
171 if len(out) == 0 {
172 return nil, fmt.Errorf("justwatch: nothing streaming with a score")
173 }
174 return out, nil
175}
176
177// Where each score stops being fair and starts being good. IMDb runs about a
178// point higher than a tomatometer for the same film, which is why they are not
179// the same number.
180const (
181 imdbGood = 7.5
182 tomatoGood = 85
183)
184
185func gradeIMDb(v float64) string {
186 switch {
187 case v == 0:
188 return ""
189 case v >= imdbGood:
190 return "good"
191 case v >= 6.5:
192 return "fair"
193 default:
194 return "poor"
195 }
196}
197
198// combineScores puts both ratings on the same nought to a hundred scale and
199// averages them. A title with no tomatometer is its IMDb score alone rather
200// than half of one, which would read as terrible for a film nobody reviewed.
201func combineScores(imdb float64, tomato int) (int, string) {
202 pct := imdb * 10
203 from := "IMDB ONLY"
204 if tomato > 0 {
205 pct = (pct + float64(tomato)) / 2
206 from = "IMDB+RT"
207 }
208 return int(math.Round(pct)), from
209}
210
211// The combined bar gets its own lines rather than either source's. Eighty is
212// where the two good lines above average out, and sixty five sits between IMDb
213// being fair at 6.5 and the tomatometer being fresh at 60.
214func gradeScore(pct int) string {
215 switch {
216 case pct >= 80:
217 return "good"
218 case pct >= 65:
219 return "fair"
220 default:
221 return "poor"
222 }
223}
224
225func gradeTomato(v int) string {
226 switch {
227 case v >= tomatoGood:
228 return "good"
229 // Sixty is Rotten Tomatoes' own line between fresh and rotten.
230 case v >= 60:
231 return "fair"
232 default:
233 return "poor"
234 }
235}
236
237// services is the subscription list in the order a title should be attributed
238// to one. JustWatch returns every way to watch something, including a dozen
239// resold channels, so "Paramount Plus Apple TV Channel" has to resolve to
240// Paramount and a title on both Netflix and a reseller has to read as Netflix.
241var services = []struct{ match, name string }{
242 {"netflix", "NETFLIX"},
243 {"hbo max", "HBO MAX"},
244 {"max", "HBO MAX"},
245 {"disney", "DISNEY+"},
246 {"hulu", "HULU"},
247 {"apple tv+", "APPLE TV+"},
248 {"paramount", "PARAMOUNT+"},
249 {"peacock", "PEACOCK"},
250 {"amazon prime video", "PRIME"},
251 {"prime video", "PRIME"},
252 {"starz", "STARZ"},
253 {"showtime", "SHOWTIME"},
254 {"apple tv", "APPLE TV+"},
255}
256
257func pickProvider(names []string) string {
258 // Sorted so the answer does not depend on the order JustWatch happened to
259 // return the offers in.
260 sort.Strings(names)
261
262 for _, svc := range services {
263 for _, name := range names {
264 lower := strings.ToLower(name)
265 // A resold channel is still that service, but "with Ads" is the
266 // same service and should not become its own row.
267 if strings.Contains(lower, svc.match) {
268 return svc.name
269 }
270 }
271 }
272 return ""
273}
274
275// imdbURL builds the link for a title, and returns nothing for an id that is
276// not one. A row labelled IMDB either opens IMDb or does not open at all, since
277// a link that says one thing and goes somewhere else is worse than no link.
278func imdbURL(id string) string {
279 id = strings.TrimSpace(id)
280 if !strings.HasPrefix(id, "tt") || len(id) < 3 {
281 return ""
282 }
283 for _, r := range id[2:] {
284 if r < '0' || r > '9' {
285 return ""
286 }
287 }
288 return "https://www.imdb.com/title/" + id + "/"
289}