repos
/ orchard main

orchard

mirror

Every 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

8.0 KB · 290 lines · Go Raw History
  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}