Schedules Direct's pictures are behind the token and capped daily, so the server keeps them: for each series or movie aired, one landscape picture, without the title written on it where there's one, near 720 wide, downloaded once into the listings' folder and served at /guide-art/ (on the API and the emulated HDHomeRun, whose XMLTV now gives absolute URLs). Up to 500 a day, the shows on soonest first; the daily cap pauses them until midnight UTC, a picture Schedules Direct hasn't got is never asked for again, and a key without any is looked up again after a week. Listings kept before pictures were fetch them at the next start. The guide hid the program's picture whenever the interface size made its top tight, on a wide window as much as a TV; it now goes only when the window is too narrow for it.
- commit
- 8e08183a288c2d63c3b0c35dd9f26e3675166dde
- parent
- e91dadef617ad2584d7da3e407fe8977b59a8fe8
- author
- FelineStateMachine <226808055+FelineStateMachine@users.noreply.github.com>
- kind
- feat risk score 1.9 of 3, Jev's estimate
| M | README.md | 6 | +- |
| M | frontend/ | 16 | +- |
| M | frontend/ | 9 | +- |
| M | internal/ | 5 | +- |
| M | internal/ | 6 | +- |
| A | internal/ | 278 | ++++++++++++++++++++++++++++++ |
| M | internal/ | 12 | +- |
| M | internal/ | 93 | ++++++++++- |
| M | internal/ | 5 | +- |
| M | internal/ | 50 | +++-- |
| M | internal/ | 6 | +- |
| M | internal/ | 6 | +- |
@@ -197,7 +197,11 @@ ZIP code's antenna lineup to the account, updates about once a day at a minute o
197197 for the station-days that changed, and stops asking altogether when the account is refused198198 (a wrong password, a locked or expired account) until it's saved again, as Schedules Direct199199 requires. The app's guide still shows its listing hours; recording rules look the two weeks200-ahead, and the emulated HDHomeRun's XMLTV carries them all. Airwaves identifies itself as200+ahead, and the emulated HDHomeRun's XMLTV carries them all. Pictures come from there too: one201+landscape picture per series or movie, picked without the title written on it where there's202+one, downloaded once and kept in `<data>/schedulesdirect/images` (served at `/guide-art/`),203+up to 500 an update, the shows on soonest first, pausing until midnight UTC when Schedules204+Direct's daily cap is reached. Airwaves identifies itself as201205 `airwaves/<version>`; it is used under Schedules Direct's personal research terms, for one202206 household.203207 @@ -728,6 +728,10 @@ function chipsFor(p, ch) {
728728 return out.join('');729729 }730730 731+// imageURL is a picture's address: the server's own ("/guide-art/...")732+// relative to the server, others as they are.733+const imageURL = (u) => (u && u.startsWith('/') ? state.boot.serverUrl + u : u || '');734+731735 // stars draws a rating out of 4 in stars, halves included.732736 const stars = (n) => '\u2605'.repeat(Math.floor(n)) + (n % 1 >= 0.5 ? '\u00bd' : '');733737 @@ -2201,6 +2205,8 @@ function fitGuideDetail() {
22012205 const g = $('#guide');22022206 const top = $('.g-top', g);22032207 const box = $('.g-detail', g);2208+ // The picture needs the details to keep a readable width beside it.2209+ top.classList.toggle('no-art', top.clientWidth < 1100);22042210 const parts = ['cast', 'desc', 'meta', 'ep', 'hint'].map((k) => f(g, k));22052211 for (const el of parts) el.classList.remove('gd-gone');22062212 box.classList.remove('one-row');@@ -2287,7 +2293,7 @@ function renderGuideDetail(ch, p) {
22872293 f(g, 'hint').innerHTML = `${when}${more ? `, ${more}` : ''}${rec}`;22882294 renderGuideActions(ch, p);22892295 const art = f(g, 'art');2290- if (p && p.image) { art.style.backgroundImage = `url('${p.image}')`; art.classList.add('has'); } else { art.style.backgroundImage = ''; art.classList.remove('has'); }2296+ if (p && p.image) { art.style.backgroundImage = `url('${imageURL(p.image)}')`; art.classList.add('has'); } else { art.style.backgroundImage = ''; art.classList.remove('has'); }22912297 }22922298 22932299 // ---------- reception ----------@@ -3461,11 +3467,11 @@ function renderRecordings() {
34613467 <div class="rd-actions">${cur.status === 'failed' ? '' : act('play', resumable(cur) ? `Resume at ${mmss(cur.position)}` : 'Play', cur)}${resumable(cur) ? act('restart', 'From the start', cur) : ''}${cur.status === 'failed' ? '' : act('watched', cur.watched ? 'Mark unwatched' : 'Mark watched', cur)}${act('delete', 'Delete', cur)}</div>34623468 <div class="rd-hint">${recHint(cur)}</div>34633469 </div>3464- <div class="rd-art" style="${cur.image ? `background-image:url('${esc(cur.image)}')` : ''}"></div>`3470+ <div class="rd-art" style="${cur.image ? `background-image:url('${esc(imageURL(cur.image))}')` : ''}"></div>`34653471 : '<div class="rd-empty">Nothing recorded yet. In the guide, OK on a program to come records it, or every episode.</div><div></div>';34663472 f(v, 'library').innerHTML = lib.map((it, i) => `34673473 <article class="r-card ${i === sel.library ? 'sel' : ''} ${it.status === 'failed' ? 'failed' : ''} ${it.watched ? 'watched' : ''}" data-i="${i}">3468- <div class="r-art" style="${it.image ? `background-image:url('${esc(it.image)}')` : ''}">${it.image ? '' : `<span>${esc(it.title)}</span>`}3474+ <div class="r-art" style="${it.image ? `background-image:url('${esc(imageURL(it.image))}')` : ''}">${it.image ? '' : `<span>${esc(it.title)}</span>`}34693475 ${resumable(it) ? `<div class="r-prog"><i style="width:${Math.min(100, (it.position / it.duration) * 100)}%"></i></div>` : ''}34703476 ${it.watched ? '<b class="r-badge">Watched</b>' : ''}</div>34713477 <div class="r-meta">@@ -4633,13 +4639,13 @@ function updateMediaSession() {
46334639 const it = state.recording;46344640 let m = null;46354641 if (it) {4636- m = { title: it.title, artist: 'Recording', album: it.subtitle || 'Airwaves', art: it.image };4642+ m = { title: it.title, artist: 'Recording', album: it.subtitle || 'Airwaves', art: imageURL(it.image) };46374643 } else if (ch && ch.weather) {46384644 m = { title: 'Local Forecast', artist: `${ch.number} ${ch.name}`, album: 'Airwaves', art: ch.logo };46394645 } else if (ch) {46404646 const p = airingAt(ch, Date.now());46414647 const name = ch.network || displayCall(ch);4642- m = { title: p ? p.title : name, artist: `${ch.number} ${name}`, album: (p && p.episodeTitle) || 'Airwaves', art: (p && p.image) || ch.logo };4648+ m = { title: p ? p.title : name, artist: `${ch.number} ${name}`, album: (p && p.episodeTitle) || 'Airwaves', art: imageURL(p && p.image) || ch.logo };46434649 }46444650 const key = JSON.stringify(m);46454651 if (key === mediaShown) return;@@ -212,10 +212,11 @@ body.mode-guide #guide, body.mode-antenna #antenna, body.mode-settings #settings
212212 .tight .gd-title { -webkit-line-clamp: 1; font-size: clamp(30px, calc(5.2 * var(--vh)), 48px); }213213 .tight .gd-meta { margin: 6px 0; }214214 .tight .gd-desc { -webkit-line-clamp: 2; }215-/* The program's picture makes way for its actions and the hint. */216-.tight .g-top { grid-template-columns: var(--pv-w) minmax(0, 1fr); }217-.tight .gd-art { display: none; }218-.tight .gd-desc, .tight .gd-hint { max-width: none; }215+/* A narrow window gives the program's picture's room to its details216+ (app.js decides, by width). */217+.g-top.no-art { grid-template-columns: var(--pv-w) minmax(0, 1fr); }218+.g-top.no-art .gd-art { display: none; }219+.g-top.no-art .gd-desc, .g-top.no-art .gd-hint { max-width: none; }219220 220221 .g-bar { height: 44px; padding: 0 28px; display: flex; align-items: center; justify-content: space-between; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); }221222 .g-filters { display: flex; gap: 2px; height: 100%; }@@ -68,8 +68,8 @@ type Server struct {
6868 // Extra serves other unauthenticated paths (weather images, the6969 // WeatherStar redirect).7070 Extra http.Handler71- // Logos serves custom channels' logos at /channel-logos/<number>,72- // unauthenticated like Extra.71+ // Logos serves custom channels' logos at /channel-logos/<number>, and72+ // the guide's pictures at /guide-art/, unauthenticated like Extra.7373 Logos http.Handler7474 // Token, when set, must be sent as "Authorization: Bearer <token>" on7575 // /api/ requests. Stream URLs are unguessable and stay open so native@@ -247,6 +247,7 @@ func (s *Server) Handler() http.Handler {
247247 }248248 if s.Logos != nil {249249 mux.Handle("/channel-logos/", s.Logos)250+ mux.Handle("/guide-art/", s.Logos)250251 }251252 if s.Admin != nil {252253 admin := s.Admin@@ -51,8 +51,9 @@ type Server struct {
5151 DeviceID uint325252 FriendlyName string5353 Tuners func() int54- // Logos serves /channel-logos/, the custom channels' logos the55- // lineup's paths point at; optional.54+ // Logos serves /channel-logos/ and /guide-art/, the custom channels'55+ // logos and the programs' pictures the lineup's and guide's paths56+ // point at; optional.5657 Logos http.Handler5758 // FromHost takes the address clients reach the device at from their5859 // requests' Host header, not the connection's local address. Set it@@ -142,6 +143,7 @@ func (s *Server) Handler() http.Handler {
142143 })143144 if s.Logos != nil {144145 mux.Handle("GET /channel-logos/{number}", s.Logos)146+ mux.Handle("GET /guide-art/{name}", s.Logos)145147 }146148 stream := func(w http.ResponseWriter, r *http.Request) {147149 number := strings.TrimPrefix(r.PathValue("ch"), "v")@@ -0,0 +1,278 @@
1+package sd2+3+import (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+)19+20+// 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.25+const (26+ maxArtChunk = 500 // keys per /metadata/programs27+ maxArtLookups = 1000 // keys looked up per update28+ maxArtFetches = 500 // downloads per update29+ artRecheck = 7 * 24 * time.Hour30+)31+32+// ArtPath is where the pictures are served from, for the guide.33+const ArtPath = "/guide-art/"34+35+// 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.38+func 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+}44+45+// 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.48+func (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+}91+92+// lookupArt asks which pictures keys have, and picks one for each.93+func (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+}115+116+// sdPicture is one picture (in one size) of a program or series.117+type 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+}124+125+// 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.128+func 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+}165+166+// 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.169+func (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+}216+217+// artFile is the file a picture is kept as: its URI's last part, which218+// Schedules Direct makes a checksum.219+func 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+}228+229+// fetchPicture downloads one picture to path. Schedules Direct answers a230+// picture with a redirect to where it is; an error comes as JSON.231+func (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+ resp, err := c.http.Do(req)243+ if err != nil {244+ return err245+ }246+ defer resp.Body.Close()247+ raw, err := io.ReadAll(io.LimitReader(resp.Body, 16<<20))248+ if err != nil {249+ return err250+ }251+ if strings.Contains(resp.Header.Get("Content-Type"), "json") || bytes.HasPrefix(bytes.TrimSpace(raw), []byte("{")) {252+ var env envelope253+ if json.Unmarshal(raw, &env) == nil && env.Code != nil && *env.Code != 0 {254+ if msg, ok := stopCodes[*env.Code]; ok {255+ return &StopError{Code: *env.Code, Message: msg}256+ }257+ return &apiError{Code: *env.Code, Response: env.Response, Message: env.Message, Status: resp.StatusCode}258+ }259+ return fmt.Errorf("not a picture (HTTP %d)", resp.StatusCode)260+ }261+ if resp.StatusCode != http.StatusOK || len(raw) == 0 {262+ return &apiError{Status: resp.StatusCode, Message: http.StatusText(resp.StatusCode)}263+ }264+ return writeFile(path, raw, 0o644)265+}266+267+// ArtHandler serves the pictures kept, at ArtPath; they never change.268+func (s *Source) ArtHandler() http.Handler {269+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {270+ name := strings.TrimPrefix(r.URL.Path, ArtPath)271+ if name == "" || name != filepath.Base(name) || strings.HasPrefix(name, ".") {272+ http.NotFound(w, r)273+ return274+ }275+ w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")276+ http.ServeFile(w, r, filepath.Join(s.Dir, "images", name))277+ })278+}@@ -185,7 +185,7 @@ func (c *client) login(ctx context.Context, fresh bool) error {
185185 186186 // update brings the state up to date: the account, the antenna lineup of187187 // zip, the schedules that changed and the programs they air.188-func (c *client) update(ctx context.Context, zip string, now time.Time) error {188+func (c *client) update(ctx context.Context, zip, dir string, now time.Time) error {189189 if err := c.login(ctx, false); err != nil {190190 return err191191 }@@ -213,6 +213,7 @@ func (c *client) update(ctx context.Context, zip string, now time.Time) error {
213213 return err214214 }215215 c.st.prune(now)216+ c.artwork(ctx, dir, now)216217 return nil217218 }218219 @@ -577,7 +578,11 @@ type sdProgram struct {
577578 IsHome bool `json:"isHome"`578579 } `json:"teams"`579580 } `json:"eventDetails"`580- HasImageArtwork bool `json:"hasImageArtwork"`581+ HasImageArtwork bool `json:"hasImageArtwork"`582+ HasSeriesArtwork bool `json:"hasSeriesArtwork"`583+ HasMovieArtwork bool `json:"hasMovieArtwork"`584+ HasSportsArtwork bool `json:"hasSportsArtwork"`585+ HasEpisodeArtwork bool `json:"hasEpisodeArtwork"`581586 }582587 583588 type sdDescription struct {@@ -608,7 +613,8 @@ func parseProgram(raw json.RawMessage) (string, program, bool) {
608613 }609614 p := program{610615 MD5: sp.MD5, EpisodeTitle: sp.EpisodeTitle150, OriginalAirDate: sp.OriginalAirDate,611- Genres: sp.Genres, Kind: sp.EntityType, Art: sp.HasImageArtwork,616+ Genres: sp.Genres, Kind: sp.EntityType,617+ Art: sp.HasImageArtwork || sp.HasSeriesArtwork || sp.HasMovieArtwork || sp.HasSportsArtwork || sp.HasEpisodeArtwork,612618 Description: cmp.Or(english(sp.Descriptions.Long), english(sp.Descriptions.Short)),613619 }614620 if len(sp.Titles) > 0 {@@ -23,6 +23,8 @@ type fakeSD struct {
2323 added bool2424 expire bool // the next request gets 4006 once2525 md5 string26+ images []string // pictures downloaded27+ capped bool // pictures answer with the daily cap2628 }2729 2830 func (f *fakeSD) ServeHTTP(w http.ResponseWriter, r *http.Request) {@@ -60,7 +62,35 @@ func (f *fakeSD) ServeHTTP(w http.ResponseWriter, r *http.Request) {
6062 return6163 }6264 today := time.Now().UTC().Format(time.DateOnly)65+ if uri, ok := strings.CutPrefix(path, "image/"); ok {66+ if f.capped {67+ reply(map[string]any{"code": 5002, "response": "MAX_IMAGE_DOWNLOADS", "message": "cap"})68+ return69+ }70+ f.images = append(f.images, uri)71+ w.Header().Set("Content-Type", "image/jpeg")72+ fmt.Fprint(w, "JPEG "+uri)73+ return74+ }6375 switch path {76+ case "metadata/programs/":77+ var ids []string78+ _ = json.NewDecoder(r.Body).Decode(&ids)79+ pics := map[string][]any{80+ "SH013657560000": {81+ map[string]any{"uri": "banner43.jpg", "width": "720", "height": "540", "aspect": "4x3", "category": "Banner-L1"},82+ map[string]any{"uri": "iconic169small.jpg", "width": 240, "height": 135, "aspect": "16x9", "category": "Iconic"},83+ map[string]any{"uri": "iconic169.jpg", "width": 960, "height": 540, "aspect": "16x9", "category": "Iconic"},84+ map[string]any{"uri": "poster.jpg", "width": 720, "height": 1080, "aspect": "2x3", "category": "Iconic"},85+ },86+ "MV000087710000": {map[string]any{"uri": "movie.jpg", "width": 720, "height": 405, "aspect": "16x9", "category": "Iconic"}},87+ }88+ var out []any89+ for _, id := range ids {90+ out = append(out, map[string]any{"programID": id, "data": pics[id]})91+ }92+ reply(out)93+ return6494 case "status":6595 lineups := []map[string]any{}6696 if f.added {@@ -123,9 +153,9 @@ func (f *fakeSD) ServeHTTP(w http.ResponseWriter, r *http.Request) {
123153 "descriptions": map[string]any{"description100": []any{map[string]any{"descriptionLanguage": "en", "description": "Short."}},124154 "description1000": []any{map[string]any{"descriptionLanguage": "en", "description": "The knockouts continue, longer."}}},125155 "cast": []any{map[string]any{"billingOrder": "02", "name": "Second"}, map[string]any{"billingOrder": "01", "name": "First", "characterName": "Coach"}},126- "metadata": []any{map[string]any{"Gracenote": map[string]any{"season": 28, "episode": 12}}}, "entityType": "Episode"},156+ "metadata": []any{map[string]any{"Gracenote": map[string]any{"season": 28, "episode": 12}}}, "entityType": "Episode", "hasSeriesArtwork": true},127157 "MV000087710000": map[string]any{"programID": "MV000087710000", "md5": "p2", "titles": []any{map[string]any{"title120": "The Wizard of Oz"}},128- "entityType": "Movie", "contentRating": []any{map[string]any{"body": "Motion Picture Association", "code": "PG"}},158+ "entityType": "Movie", "hasMovieArtwork": true, "contentRating": []any{map[string]any{"body": "Motion Picture Association", "code": "PG"}},129159 "movie": map[string]any{"year": "1939", "duration": 6120, "qualityRating": []any{map[string]any{"ratingsBody": "Gracenote", "rating": "4"}}}},130160 "SP003190180000": map[string]any{"programID": "SP003190180000", "md5": "p3", "titles": []any{map[string]any{"title120": "NFL Football"}},131161 "entityType": "Sports", "genres": []string{"Sports event", "Football"},@@ -368,3 +398,62 @@ func TestOldLogos(t *testing.T) {
368398 t.Fatalf("after: logo %q, %d schedules asked, calls %v", g.Channels[0].Logo, len(f.asked)-asked, f.calls)369399 }370400 }401+402+// TestArtwork: pictures are picked (landscape, without the title on it,403+// near 720 wide), downloaded once, served, and paused by the daily cap.404+func TestArtwork(t *testing.T) {405+ s, f := newSource(t)406+ _ = s.SetAccount("viewer", "hunter2")407+ a, _ := LoadAccount(s.accountPath())408+ s.update(context.Background(), a)409+ ps := s.Guide().Programs["20370"]410+ if ps[0].Image != ArtPath+"iconic169.jpg" || ps[1].Image != ArtPath+"movie.jpg" {411+ t.Fatalf("images %q, %q", ps[0].Image, ps[1].Image)412+ }413+ if len(f.images) != 2 {414+ t.Fatalf("downloaded %v", f.images)415+ }416+ rec := httptest.NewRecorder()417+ s.ArtHandler().ServeHTTP(rec, httptest.NewRequest("GET", ArtPath+"iconic169.jpg", nil))418+ if rec.Code != 200 || rec.Body.String() != "JPEG iconic169.jpg" {419+ t.Fatalf("served %d %q", rec.Code, rec.Body.String())420+ }421+ for _, bad := range []string{ArtPath + "../account.json", ArtPath + ".hidden", ArtPath} {422+ rec := httptest.NewRecorder()423+ s.ArtHandler().ServeHTTP(rec, httptest.NewRequest("GET", bad, nil))424+ if rec.Code != http.StatusNotFound {425+ t.Errorf("%s: %d", bad, rec.Code)426+ }427+ }428+429+ // Kept: nothing is downloaded again, nor looked up.430+ f.mu.Lock()431+ f.md5 = "day9"432+ f.mu.Unlock()433+ s.update(context.Background(), a)434+ if len(f.images) != 2 || f.count("POST metadata/programs/") != 1 {435+ t.Fatalf("again: downloaded %v, %d lookups", f.images, f.count("POST metadata/programs/"))436+ }437+}438+439+// TestArtworkCap: the daily cap pauses downloads until midnight UTC; the440+// listings update anyway.441+func TestArtworkCap(t *testing.T) {442+ s, f := newSource(t)443+ f.capped = true444+ _ = s.SetAccount("viewer", "hunter2")445+ a, _ := LoadAccount(s.accountPath())446+ s.update(context.Background(), a)447+ if st := s.Status(); !st.OK {448+ t.Fatalf("status %+v", st)449+ }450+ s.mu.Lock()451+ paused := s.st.ImagesPaused452+ s.mu.Unlock()453+ if !paused.After(time.Now()) || paused.Hour() != 0 || paused.Sub(time.Now()) > 24*time.Hour {454+ t.Fatalf("paused until %s", paused)455+ }456+ if s.Guide().Programs["20370"][0].Image != "" {457+ t.Fatal("a picture without a download")458+ }459+}@@ -93,6 +93,9 @@ func (s *Source) init() {
9393 st.LineupModified = "" // read the lineup again, for its logos9494 st.Updated = time.Time{}9595 }96+ if len(st.Programs) > 0 && len(st.ArtChecked) == 0 {97+ st.Updated = time.Time{} // kept before pictures were: fetch them98+ }9699 s.mu.Lock()97100 defer s.mu.Unlock()98101 s.st = st@@ -253,7 +256,7 @@ func (s *Source) update(ctx context.Context, a Account) {
253256 s.mu.Unlock()254257 255258 c := &client{base: s.BaseURL, http: s.HTTP, ua: s.UserAgent, account: a, st: st}256- err := c.update(ctx, s.ZIP, time.Now())259+ err := c.update(ctx, s.ZIP, filepath.Join(s.Dir, "images"), time.Now())257260 var stop *StopError258261 if errors.As(err, &stop) {259262 st.Stopped = stop.Error()@@ -34,9 +34,16 @@ type state struct {
3434 Days map[string]map[string]day `json:"days,omitempty"`3535 // Programs are the programs aired, by program ID.3636 Programs map[string]program `json:"programs,omitempty"`37- // Art is the best picture of each series or program, by the ID it was38- // asked for: a program's, or its series' first 10 characters.39- Art map[string]string `json:"art,omitempty"`37+ // Art is the picture kept for each series or program, by artKey: its38+ // file in the images folder. ArtWanted are those picked and not yet39+ // downloaded (their URIs), ArtChecked when each key was last looked40+ // up, BadArt URIs Schedules Direct hasn't got, and ImagesPaused when41+ // downloads may start again after the daily cap.42+ Art map[string]string `json:"art,omitempty"`43+ ArtWanted map[string]string `json:"artWanted,omitempty"`44+ ArtChecked map[string]time.Time `json:"artChecked,omitempty"`45+ BadArt map[string]bool `json:"badArt,omitempty"`46+ ImagesPaused time.Time `json:"imagesPaused,omitzero"`4047 }4148 4249 type station struct {@@ -169,13 +176,16 @@ func (st *state) clone() *state {
169176 c.Days[id] = maps.Clone(days)170177 }171178 c.Programs = maps.Clone(st.Programs)172- c.Art = maps.Clone(st.Art)179+ c.Art, c.ArtWanted, c.ArtChecked, c.BadArt = maps.Clone(st.Art), maps.Clone(st.ArtWanted), maps.Clone(st.ArtChecked), maps.Clone(st.BadArt)173180 if c.Programs == nil {174181 c.Programs = map[string]program{}175182 }176183 if c.Art == nil {177184 c.Art = map[string]string{}178185 }186+ if c.ArtWanted == nil {187+ c.ArtWanted = map[string]string{}188+ }179189 return &c180190 }181191 @@ -203,20 +213,22 @@ func (st *state) prune(now time.Time) {
203213 delete(st.Programs, id)204214 }205215 }206- for id := range st.Art {207- if !used[id] && !slices.ContainsFunc(slices.Collect(maps.Keys(used)), func(p string) bool { return seriesKey(p) == id }) {208- delete(st.Art, id)216+ // Pictures are kept (they're files, and a series comes back); what217+ // was only being looked up for programs gone is dropped.218+ keys := map[string]bool{}219+ for id := range used {220+ keys[artKey(id)] = true221+ }222+ for k := range st.ArtWanted {223+ if !keys[k] {224+ delete(st.ArtWanted, k)209225 }210226 }211-}212-213-// seriesKey is what a program's series pictures are kept under: its ID's214-// first 10 characters.215-func seriesKey(programID string) string {216- if len(programID) < 10 {217- return programID227+ for k, t := range st.ArtChecked {228+ if !keys[k] && now.Sub(t) > artRecheck {229+ delete(st.ArtChecked, k)230+ }218231 }219- return programID[:10]220232 }221233 222234 // SeriesID is a program's series as the guide's grid gives it, so series@@ -229,10 +241,6 @@ func SeriesID(programID string) string {
229241 return "SH" + programID[2:10]230242 }231243 232-// imageBase is where Gracenote's pictures are served, the same files233-// Schedules Direct names.234-const imageBase = "https://zpmc.tmsimg.com/assets/"235-236244 // guide is the listings as the guide has them, from a day before now on;237245 // nil while there are none.238246 func (st *state) guide(zip string, now time.Time) *guide.Guide {@@ -288,8 +296,8 @@ func (st *state) program(a airing, end time.Time) guide.Program {
288296 }289297 }290298 slices.Sort(out.Flags)291- if img := cmp.Or(st.Art[a.ProgramID], st.Art[seriesKey(a.ProgramID)]); img != "" {292- out.Image = imageBase + img + "?w=360"299+ if img := st.Art[artKey(a.ProgramID)]; img != "" {300+ out.Image = ArtPath + img293301 }294302 return out295303 }@@ -206,7 +206,11 @@ func (b hdhrBackend) XMLTV(ctx context.Context, w io.Writer, abs func(path strin
206206 x.Episode = fmt.Sprintf("S%sE%s", p.Season, p.Episode)207207 }208208 if p.Image != "" {209- x.Icon = &xmltvIcon{Src: p.Image}209+ src := p.Image210+ if strings.HasPrefix(src, "/") {211+ src = abs(src)212+ }213+ x.Icon = &xmltvIcon{Src: src}210214 }211215 if p.New {212216 x.New = &struct{}{}@@ -576,9 +576,13 @@ func logoPath(d vchan.Details) string {
576576 }577577 578578 // LogoHandler serves /channel-logos/<number>: custom channels' logos, off579-// or on, for the app and HDHomeRun clients.579+// or on, for the app and HDHomeRun clients; and /guide-art/<name>, the580+// programs' pictures from Schedules Direct.580581 func (s *Service) LogoHandler() http.Handler {581582 mux := http.NewServeMux()583+ if s.listings != nil {584+ mux.Handle("GET "+sd.ArtPath+"{name}", s.listings.ArtHandler())585+ }582586 mux.HandleFunc("GET /channel-logos/{number}", func(w http.ResponseWriter, r *http.Request) {583587 number := r.PathValue("number")584588 var chans []vchan.Channel