clone

a desktop tv and program guide for over-the-air broadcasts: what's on air, what your tuner gets, live tv, recordings

main
airwaves / internal / sd / art.go
art.go 8.2K
1package sd23import (4 "bytes"5 "cmp"6 "context"7 "encoding/json"8 "errors"9 "fmt"10 "io"11 "log"12 "net/http"13 "os"14 "path/filepath"15 "slices"16 "strings"17 "time"18)1920// Pictures: Schedules Direct has Gracenote's artwork for most series and21// movies, behind the token and with a daily cap on downloads. Each one is22// downloaded once and kept, a few hundred an update at most, the shows on23// soonest first; a key without pictures is asked about again only after24// a week.25const (26 maxArtChunk = 500 // keys per /metadata/programs27 maxArtLookups = 2000 // keys looked up per update28 maxArtFetches = 2000 // downloads per update, of a subscriber's 5000 a day29 artRecheck = 7 * 24 * time.Hour30)3132// ArtPath is where the pictures are served from, for the guide.33const ArtPath = "/guide-art/"3435// artKey is what a program's picture is kept under: its series' ID for an36// episode or a show ("SH" and the series number, 14 characters, where37// Schedules Direct keeps series artwork), else the program's own.38func artKey(programID string) string {39 if len(programID) == 14 && (strings.HasPrefix(programID, "EP") || strings.HasPrefix(programID, "SH")) {40 return "SH" + programID[2:10] + "0000"41 }42 return programID43}4445// artwork looks up pictures for the programs aired that have none yet,46// and downloads the chosen ones it hasn't got, into dir. It never fails47// the update: pictures are a nicety, and a cap only pauses them.48func (c *client) artwork(ctx context.Context, dir string, now time.Time) {49 if now.Before(c.st.ImagesPaused) {50 return51 }52 if c.st.Art == nil {53 c.st.Art = map[string]string{}54 }55 if c.st.ArtChecked == nil {56 c.st.ArtChecked = map[string]time.Time{}57 }58 // The keys to look up, by when they first air.59 first := map[string]time.Time{}60 for _, days := range c.st.Days {61 for _, d := range days {62 for _, a := range d.Airings {63 if p, ok := c.st.Programs[a.ProgramID]; !ok || !p.Art {64 continue65 }66 k := artKey(a.ProgramID)67 if t, ok := first[k]; !ok || a.Start.Before(t) {68 first[k] = a.Start69 }70 }71 }72 }73 var keys []string74 for k := range first {75 if c.st.Art[k] == "" && now.Sub(c.st.ArtChecked[k]) > artRecheck {76 keys = append(keys, k)77 }78 }79 slices.SortFunc(keys, func(a, b string) int { return cmp.Or(first[a].Compare(first[b]), strings.Compare(a, b)) })80 if len(keys) > maxArtLookups {81 keys = keys[:maxArtLookups]82 }83 for chunk := range slices.Chunk(keys, maxArtChunk) {84 if err := c.lookupArt(ctx, chunk, now); err != nil {85 log.Printf("listings: artwork: %v", err)86 return87 }88 }89 c.fetchArt(ctx, dir, now)90}9192// lookupArt asks which pictures keys have, and picks one for each.93func (c *client) lookupArt(ctx context.Context, keys []string, now time.Time) error {94 var out []struct {95 ProgramID string `json:"programID"`96 Code int `json:"code"`97 Data []sdPicture `json:"data"`98 }99 if err := c.do(ctx, http.MethodPost, "metadata/programs/", keys, &out); err != nil {100 return err101 }102 for _, k := range keys {103 c.st.ArtChecked[k] = now104 }105 for _, e := range out {106 if e.Code != 0 {107 continue108 }109 if uri := pickPicture(e.Data); uri != "" {110 c.st.ArtWanted[e.ProgramID] = uri111 }112 }113 return nil114}115116// sdPicture is one picture (in one size) of a program or series.117type sdPicture struct {118 URI string `json:"uri"`119 Width flexInt `json:"width"`120 Height flexInt `json:"height"`121 Aspect string `json:"aspect"`122 Category string `json:"category"`123}124125// pickPicture picks the picture for the guide: landscape (16:9, else126// 4:3) and without the title written on it ("Iconic"), else with it, at127// about 720 pixels wide.128func pickPicture(ps []sdPicture) string {129 categories := []string{"Iconic", "Banner-L2", "Banner-L1", "Banner-L3", "Banner", "Staple", "Banner-LO"}130 rank := func(p sdPicture) (int, int, int) {131 c := slices.Index(categories, p.Category)132 if c < 0 {133 c = len(categories)134 }135 a := map[string]int{"16x9": 0, "4x3": 1}[p.Aspect]136 if p.Aspect != "16x9" && p.Aspect != "4x3" {137 a = 2138 }139 w := int(p.Width) - 720140 if w < 0 {141 w = -w * 2 // smaller than wanted is worse than larger142 }143 return a, c, w144 }145 best := -1146 for i, p := range ps {147 if p.URI == "" {148 continue149 }150 if best < 0 {151 best = i152 continue153 }154 a1, c1, w1 := rank(p)155 a2, c2, w2 := rank(ps[best])156 if cmp.Or(cmp.Compare(a1, a2), cmp.Compare(c1, c2), cmp.Compare(w1, w2)) < 0 {157 best = i158 }159 }160 if best < 0 {161 return ""162 }163 return ps[best].URI164}165166// fetchArt downloads the pictures picked and not yet kept, up to167// maxArtFetches; a daily cap pauses them until midnight UTC, and a picture168// Schedules Direct hasn't got isn't asked for again.169func (c *client) fetchArt(ctx context.Context, dir string, now time.Time) {170 keys := slices.Sorted(func(yield func(string) bool) {171 for k := range c.st.ArtWanted {172 if !yield(k) {173 return174 }175 }176 })177 fetched := 0178 for _, k := range keys {179 uri := c.st.ArtWanted[k]180 if c.st.BadArt[uri] {181 delete(c.st.ArtWanted, k)182 continue183 }184 name := artFile(uri)185 if _, err := os.Stat(filepath.Join(dir, name)); err == nil {186 c.st.Art[k] = name187 delete(c.st.ArtWanted, k)188 continue189 }190 if fetched == maxArtFetches || ctx.Err() != nil {191 return192 }193 fetched++194 err := c.fetchPicture(ctx, uri, filepath.Join(dir, name))195 var ae *apiError196 switch {197 case err == nil:198 c.st.Art[k] = name199 delete(c.st.ArtWanted, k)200 case errors.As(err, &ae) && (ae.Code == 5002 || ae.Code == 5003 || ae.Code == 5004):201 c.st.ImagesPaused = now.UTC().Truncate(24 * time.Hour).Add(24 * time.Hour)202 log.Printf("listings: picture downloads paused until %s: %v", c.st.ImagesPaused.Format(time.RFC3339), err)203 return204 case errors.As(err, &ae) && ae.Code == 5000:205 if c.st.BadArt == nil {206 c.st.BadArt = map[string]bool{}207 }208 c.st.BadArt[uri] = true209 delete(c.st.ArtWanted, k)210 default:211 log.Printf("listings: picture %s: %v", uri, err)212 return213 }214 }215}216217// artFile is the file a picture is kept as: its URI's last part, which218// Schedules Direct makes a checksum.219func artFile(uri string) string {220 name := filepath.Base(strings.Split(uri, "?")[0])221 return strings.Map(func(r rune) rune {222 if r == '/' || r == '\\' || r < 0x20 {223 return '_'224 }225 return r226 }, name)227}228229// fetchPicture downloads one picture to path. Schedules Direct answers a230// picture with a redirect to where it is; an error comes as JSON.231func (c *client) fetchPicture(ctx context.Context, uri, path string) error {232 u := uri233 if !strings.HasPrefix(u, "https://") {234 u = cmp.Or(c.base, API) + "image/" + strings.TrimPrefix(uri, "/")235 }236 req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)237 if err != nil {238 return err239 }240 req.Header.Set("User-Agent", c.ua)241 req.Header.Set("Token", c.st.Token)242 // The picture is wherever it redirects to (S3): the token isn't for243 // there.244 hc := *c.http245 hc.CheckRedirect = func(r *http.Request, via []*http.Request) error {246 if len(via) >= 5 {247 return errors.New("too many redirects")248 }249 if r.URL.Host != via[0].URL.Host {250 r.Header.Del("Token")251 }252 return nil253 }254 resp, err := hc.Do(req)255 if err != nil {256 return err257 }258 defer resp.Body.Close()259 raw, err := io.ReadAll(io.LimitReader(resp.Body, 16<<20))260 if err != nil {261 return err262 }263 if strings.Contains(resp.Header.Get("Content-Type"), "json") || bytes.HasPrefix(bytes.TrimSpace(raw), []byte("{")) {264 var env envelope265 if json.Unmarshal(raw, &env) == nil && env.Code != nil && *env.Code != 0 {266 if msg, ok := stopCodes[*env.Code]; ok {267 return &StopError{Code: *env.Code, Message: msg}268 }269 return &apiError{Code: *env.Code, Response: env.Response, Message: env.Message, Status: resp.StatusCode}270 }271 return fmt.Errorf("not a picture (HTTP %d)", resp.StatusCode)272 }273 if resp.StatusCode != http.StatusOK || len(raw) == 0 {274 return &apiError{Status: resp.StatusCode, Message: http.StatusText(resp.StatusCode)}275 }276 return writeFile(path, raw, 0o644)277}278279// ArtHandler serves the pictures kept, at ArtPath; they never change.280func (s *Source) ArtHandler() http.Handler {281 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {282 name := strings.TrimPrefix(r.URL.Path, ArtPath)283 if name == "" || name != filepath.Base(name) || strings.HasPrefix(name, ".") {284 http.NotFound(w, r)285 return286 }287 w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")288 http.ServeFile(w, r, filepath.Join(s.Dir, "images", name))289 })290}