clone

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

Show Schedules Direct's pictures, and the guide's picture whenever there's room

FelineStateMachine committed 8e08183
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
12 files changed, +449 -43
MREADME.md6+-
Mfrontend/dist/app.js16+-
Mfrontend/dist/style.css9+-
Minternal/api/api.go5+-
Minternal/hdhr/hdhr.go6+-
Ainternal/sd/art.go278++++++++++++++++++++++++++++++
Minternal/sd/client.go12+-
Minternal/sd/sd_test.go93++++++++++-
Minternal/sd/source.go5+-
Minternal/sd/state.go50+++--
Minternal/service/hdhr.go6+-
Minternal/service/service.go6+-
M README.md +5 -1
M frontend/dist/app.js +11 -5
@@ -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;
M frontend/dist/style.css +5 -4
M internal/api/api.go +3 -2
M internal/hdhr/hdhr.go +4 -2
A internal/sd/art.go +278 -0
@@ -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+}
M internal/sd/client.go +9 -3
@@ -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 {
M internal/sd/sd_test.go +91 -2
@@ -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+}
M internal/sd/source.go +4 -1
M internal/sd/state.go +29 -21
@@ -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 }
M internal/service/hdhr.go +5 -1
M internal/service/service.go +5 -1