From d9de55093a1eec19705d842e966f515f5101d0d8 Mon Sep 17 00:00:00 2001 From: Daniel Widrick Date: Sat, 12 Sep 2026 23:34:12 -0400 Subject: [PATCH] Serve the provider lineup at /api/lineup.json One entry per channel position, sorted by number, with station metadata, Gracenote station filters, and the resolved logo. Provider name, type, and location come from the saved setup when it matches the lineup the guide was built from. Returns 503 with Retry-After until the first guide exists, matching /api/guide.json. --- CLAUDE.md | 4 +- README.md | 36 ++++++++ lineup_handler_test.go | 195 +++++++++++++++++++++++++++++++++++++++++ main.go | 100 +++++++++++++++++++++ 4 files changed, 333 insertions(+), 2 deletions(-) create mode 100644 lineup_handler_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 9dc0268..1a9735e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,7 +33,7 @@ The binary is a single Go process that scrapes GraceNote/TMS for 14 days of TV l 1. `/setup` uses `web.ProviderClient` to discover Gracenote lineups by country and postal code. `appconfig.Store` persists the selected non-secret source in `config.json`; complete legacy `GN_*` settings can bootstrap it. 2. `web.Client.GetDataByTime` fetches 6-hour grid slices from the GraceNote API (`tvlistings.gracenote.com/api/grid`) — 56 slots for 14 days. A 5-second sleep separates requests. Raw JSON types live in `web/web.go`. -3. `guide.ConvertChannel` / `guide.ConvertEvent` translate the raw JSON into `guide.TVGuide` (internal canonical types). The `guide.tmpl` template renders these to XMLTV. `index.html`, `setup.html`, and `guide.tmpl` are embedded at build time via `//go:embed`. +3. `guide.ConvertChannel` / `guide.ConvertEvent` translate the raw JSON into `guide.TVGuide` (internal canonical types). `TVGuide.Channels` is deduplicated by station for XMLTV; `TVGuide.Lineup` retains every provider position (number plus station) and is served by `/api/lineup.json`. `TVGuide.Source` records the lineup the guide came from. The `guide.tmpl` template renders these to XMLTV. `index.html`, `setup.html`, and `guide.tmpl` are embedded at build time via `//go:embed`. 4. `tmdb.Client.Lookup` enriches programs (poster images, ratings, overview, year) via TMDB search API. Deduplicates by `(title, isMovie)` before hitting the API. Rate-limited to ~4 req/sec. 5. `tvlogo.Client.Resolve` replaces Gracenote channel icons with verified PNGs from `github.com/tv-logo/tv-logos`. Generates candidate URL slugs from callsign/affiliate name and HEAD-checks each (rate-limited to ~5 req/sec). 6. `fixDeadImageURLs` rewrites `zap2it.tmsimg.com` → `tmsimg.com` for broken Gracenote image URLs. @@ -43,7 +43,7 @@ The binary is a single Go process that scrapes GraceNote/TMS for 14 days of TV l | Cache | File | TTL | |---|---|---| -| Guide (in-memory + disk) | `guide_cache.json` | 4h (startup skip) / 24h (rescrape) | +| Guide (in-memory + disk) | `guide_cache.json` | 4h (startup skip) / 24h (rescrape); carries a schema version, older caches are rebuilt once | | TMDB lookups | `tmdb_cache.json` | 7 days | | TV logo HEAD checks | `tvlogo_cache.json` | persisted, no expiry | | Image proxy | `image_cache/` dir | indefinite (per-URL SHA256 key) | diff --git a/README.md b/README.md index ae97626..cb7664c 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,7 @@ A saved `CONFIG_PATH` selection takes precedence over legacy `GN_*` settings. De | `POST /api/setup/provider` | Save the selected provider and queue a fresh guide | | `GET /xmlguide.xmltv` | XMLTV guide data (point your DVR here) | | `GET /api/guide.json` | Guide data as JSON | +| `GET /api/lineup.json` | Every channel position in the active provider lineup as JSON (see below) | | `GET /` | The Grid — built-in web UI | | `GET /img?url=...` | Image proxy with local cache | | `GET /api/livetv/config` | Returns `{"enabled":true/false}` — whether Jellyfin live TV is configured | @@ -125,6 +126,41 @@ A saved `CONFIG_PATH` selection takes precedence over legacy `GN_*` settings. De | `GET /api/livetv/tune?id=` | Starts a live stream for the given channel and returns an HLS URL | | `POST /api/livetv/stop` | Forwards a playback-stop notification to Jellyfin to end a live stream | +### Lineup JSON + +`/api/lineup.json` describes the provider lineup itself rather than the schedule: one entry per channel number, never collapsed, so a station carried at two numbers appears twice. It returns `503` with a `Retry-After` header until the first guide has been built. + +```json +{ + "generated": "2026-09-13T04:10:22Z", + "source": { + "providerName": "Local Over the Air Broadcast", + "providerType": "OTA", + "location": "", + "lineupId": "USA-lineupId-DEFAULT", + "headendId": "lineupId", + "postalCode": "13490", + "country": "USA", + "device": "-", + "language": "en-us" + }, + "positions": [ + { + "number": "2.1", + "stationId": "53158", + "placementId": "531580", + "callSign": "WKTVDT", + "affiliate": "NATIONAL BROADCASTING COMPANY", + "affiliateCallSign": "", + "filters": ["sports", "news"], + "logoUrl": "https://raw.githubusercontent.com/tv-logo/tv-logos/main/countries/united-states/nbc-us.png" + } + ] +} +``` + +`filters` are Gracenote's own station tags with the `filter-` prefix removed. `placementId` is Gracenote's row identifier and is not stable across scrapes; use `number` plus `stationId` to identify a position. `providerName`, `providerType`, and `location` are filled from the saved setup when it matches the lineup the guide was built from. + ## The Grid The server includes a built-in retro-styled TV guide web UI at the root URL. If no provider is configured, `/` redirects to `/setup`. Once configured, the guide auto-scrolls through your channel lineup and shows program details, posters, and metadata. Handy for a quick glance at what's on without opening your DVR app. diff --git a/lineup_handler_test.go b/lineup_handler_test.go new file mode 100644 index 0000000..5e4b17c --- /dev/null +++ b/lineup_handler_test.go @@ -0,0 +1,195 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "reflect" + "sort" + "testing" + "time" + + "github.com/daniel-widrick/GraceNoteScraper/appconfig" + "github.com/daniel-widrick/GraceNoteScraper/guide" +) + +func lineupTestConfig() appconfig.Config { + return appconfig.Config{ + Version: appconfig.CurrentVersion, + Gracenote: appconfig.GracenoteConfig{ + Country: "USA", PostalCode: "13490", Language: "en-us", ProviderType: "OTA", Device: "-", + LineupID: "USA-lineupId-DEFAULT", ProviderName: "Local Over the Air Broadcast", Location: "Utica", HeadendID: "lineupId", + }, + } +} + +func lineupTestGuide() *guide.TVGuide { + return &guide.TVGuide{ + Lineup: []guide.LineupPosition{ + {ChannelNo: "10", StationID: "s10", PlacementID: "s100", CallSign: "TEN", Affiliate: "Ten Net", Filters: []string{"news"}, LogoURL: "http://logo/ten.png"}, + {ChannelNo: "2.1", StationID: "s2", PlacementID: "s20", CallSign: "TWO", Affiliate: "Two Net", AffiliateCallSign: "TW"}, + {ChannelNo: "1002", StationID: "s2", PlacementID: "s299", CallSign: "TWO", Affiliate: "Two Net", AffiliateCallSign: "TW"}, + }, + Source: guide.Source{ + Country: "USA", PostalCode: "13490", HeadendID: "lineupId", LineupID: "USA-lineupId-DEFAULT", Device: "-", Language: "en-us", + GeneratedAt: time.Date(2026, 9, 13, 4, 10, 22, 0, time.UTC), + }, + } +} + +func newLineupServer(t *testing.T, g *guide.TVGuide, save bool) http.Handler { + t.Helper() + store, err := appconfig.LoadStore(filepath.Join(t.TempDir(), "config.json")) + if err != nil { + t.Fatalf("LoadStore: %v", err) + } + if save { + if err := store.Save(lineupTestConfig()); err != nil { + t.Fatalf("Save: %v", err) + } + } + state := &GuideState{} + state.Update(g) + return handleLineupJSON(state, store) +} + +func TestLineupJSONUnavailableBeforeFirstGuide(t *testing.T) { + rec := httptest.NewRecorder() + newLineupServer(t, nil, true).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/lineup.json", nil)) + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d", rec.Code) + } + if rec.Header().Get("Retry-After") != "30" { + t.Fatalf("Retry-After = %q", rec.Header().Get("Retry-After")) + } +} + +func TestLineupJSONResponse(t *testing.T) { + rec := httptest.NewRecorder() + newLineupServer(t, lineupTestGuide(), true).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/lineup.json", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String()) + } + if ct := rec.Header().Get("Content-Type"); ct != "application/json" { + t.Errorf("Content-Type = %q", ct) + } + if rec.Header().Get("Access-Control-Allow-Origin") != "*" { + t.Error("CORS header missing") + } + + var got APILineup + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if got.Generated != "2026-09-13T04:10:22Z" { + t.Errorf("generated = %q", got.Generated) + } + wantSource := APILineupSource{ + ProviderName: "Local Over the Air Broadcast", ProviderType: "OTA", Location: "Utica", + LineupID: "USA-lineupId-DEFAULT", HeadendID: "lineupId", PostalCode: "13490", Country: "USA", Device: "-", Language: "en-us", + } + if got.Source != wantSource { + t.Errorf("source = %+v\nwant %+v", got.Source, wantSource) + } + + var numbers []string + for _, p := range got.Positions { + numbers = append(numbers, p.Number) + } + if !reflect.DeepEqual(numbers, []string{"2.1", "10", "1002"}) { + t.Errorf("positions not sorted by number: %v", numbers) + } + ten := got.Positions[1] + want := APILineupPosition{Number: "10", StationID: "s10", PlacementID: "s100", CallSign: "TEN", Affiliate: "Ten Net", Filters: []string{"news"}, LogoURL: "http://logo/ten.png"} + if !reflect.DeepEqual(ten, want) { + t.Errorf("position = %+v\nwant %+v", ten, want) + } + if got.Positions[0].StationID != "s2" || got.Positions[2].StationID != "s2" { + t.Error("the same station at two numbers must appear twice") + } +} + +func TestLineupJSONKeySetIsStable(t *testing.T) { + rec := httptest.NewRecorder() + newLineupServer(t, lineupTestGuide(), true).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/lineup.json", nil)) + + var raw map[string]json.RawMessage + if err := json.Unmarshal(rec.Body.Bytes(), &raw); err != nil { + t.Fatal(err) + } + if keys := sortedKeys(raw); !reflect.DeepEqual(keys, []string{"generated", "positions", "source"}) { + t.Errorf("top-level keys = %v", keys) + } + var source map[string]json.RawMessage + if err := json.Unmarshal(raw["source"], &source); err != nil { + t.Fatal(err) + } + if keys := sortedKeys(source); !reflect.DeepEqual(keys, []string{"country", "device", "headendId", "language", "lineupId", "location", "postalCode", "providerName", "providerType"}) { + t.Errorf("source keys = %v", keys) + } + var positions []map[string]json.RawMessage + if err := json.Unmarshal(raw["positions"], &positions); err != nil { + t.Fatal(err) + } + // Position with filters carries the filters key; one without omits it. + withFilters := sortedKeys(positions[1]) + if !reflect.DeepEqual(withFilters, []string{"affiliate", "affiliateCallSign", "callSign", "filters", "logoUrl", "number", "placementId", "stationId"}) { + t.Errorf("position keys = %v", withFilters) + } + if _, ok := positions[0]["filters"]; ok { + t.Error("filters should be omitted when empty") + } + if _, ok := positions[0]["logoUrl"]; !ok { + t.Error("logoUrl must always be present, even when empty") + } +} + +func TestLineupJSONSourceNamingRequiresMatchingConfig(t *testing.T) { + // No saved configuration at all. + rec := httptest.NewRecorder() + newLineupServer(t, lineupTestGuide(), false).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/lineup.json", nil)) + var got APILineup + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if got.Source.ProviderName != "" || got.Source.ProviderType != "" || got.Source.Location != "" { + t.Errorf("provider naming should be blank without config: %+v", got.Source) + } + if got.Source.LineupID != "USA-lineupId-DEFAULT" { + t.Errorf("guide source fields must still be present: %+v", got.Source) + } + + // Saved configuration describes a different lineup than the guide. + g := lineupTestGuide() + g.Source.LineupID = "USA-OTHER-DEFAULT" + rec = httptest.NewRecorder() + newLineupServer(t, g, true).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/lineup.json", nil)) + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if got.Source.ProviderName != "" { + t.Errorf("provider name leaked across lineups: %+v", got.Source) + } + if got.Source.LineupID != "USA-OTHER-DEFAULT" { + t.Errorf("lineup id should come from the guide: %+v", got.Source) + } +} + +func TestLineupJSONDoesNotMutateGuideOrder(t *testing.T) { + g := lineupTestGuide() + before := append([]guide.LineupPosition(nil), g.Lineup...) + lineupToJSON(g, lineupTestConfig(), true) + if !reflect.DeepEqual(before, g.Lineup) { + t.Fatal("lineupToJSON must sort a copy, not the live guide") + } +} + +func sortedKeys(m map[string]json.RawMessage) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} diff --git a/main.go b/main.go index 8f9424f..0197102 100644 --- a/main.go +++ b/main.go @@ -87,6 +87,37 @@ type APIProgram struct { Description string `json:"description,omitempty"` } +// APILineup is the response shape of /api/lineup.json: every provider +// position for the active lineup, plus the source it came from. +type APILineup struct { + Generated string `json:"generated"` + Source APILineupSource `json:"source"` + Positions []APILineupPosition `json:"positions"` +} + +type APILineupSource struct { + ProviderName string `json:"providerName"` + ProviderType string `json:"providerType"` + Location string `json:"location"` + LineupID string `json:"lineupId"` + HeadendID string `json:"headendId"` + PostalCode string `json:"postalCode"` + Country string `json:"country"` + Device string `json:"device"` + Language string `json:"language"` +} + +type APILineupPosition struct { + Number string `json:"number"` + StationID string `json:"stationId"` + PlacementID string `json:"placementId"` + CallSign string `json:"callSign"` + Affiliate string `json:"affiliate"` + AffiliateCallSign string `json:"affiliateCallSign"` + Filters []string `json:"filters,omitempty"` + LogoURL string `json:"logoUrl"` +} + // ---------- Conversion ---------- // guideToJSON converts a TVGuide into the simplified JSON API format. @@ -154,6 +185,56 @@ func guideToJSON(g *guide.TVGuide) APIGuide { } } +// lineupToJSON converts a guide's lineup into the API shape. Provider naming +// comes from the saved configuration only when it describes the same source +// the guide was built from. +func lineupToJSON(g *guide.TVGuide, config appconfig.Config, configured bool) APILineup { + positions := make([]guide.LineupPosition, len(g.Lineup)) + copy(positions, g.Lineup) + guide.SortLineup(positions) + + out := APILineup{ + Generated: g.Source.GeneratedAt.UTC().Format(time.RFC3339), + Source: APILineupSource{ + LineupID: g.Source.LineupID, + HeadendID: g.Source.HeadendID, + PostalCode: g.Source.PostalCode, + Country: g.Source.Country, + Device: g.Source.Device, + Language: g.Source.Language, + }, + Positions: make([]APILineupPosition, 0, len(positions)), + } + if g.Source.GeneratedAt.IsZero() { + out.Generated = time.Now().UTC().Format(time.RFC3339) + } + if configured && sourceMatchesConfig(g.Source, config) { + out.Source.ProviderName = config.Gracenote.ProviderName + out.Source.ProviderType = config.Gracenote.ProviderType + out.Source.Location = config.Gracenote.Location + } + for _, p := range positions { + out.Positions = append(out.Positions, APILineupPosition{ + Number: p.ChannelNo, + StationID: p.StationID, + PlacementID: p.PlacementID, + CallSign: p.CallSign, + Affiliate: p.Affiliate, + AffiliateCallSign: p.AffiliateCallSign, + Filters: p.Filters, + LogoURL: p.LogoURL, + }) + } + return out +} + +// sourceMatchesConfig reports whether a guide was built from the configured lineup. +func sourceMatchesConfig(src guide.Source, config appconfig.Config) bool { + p := config.Preferences() + return src.LineupID == p.LineupId && src.HeadendID == p.Headend && src.PostalCode == p.ZipCode && + src.Country == p.Country && src.Device == p.Device && src.Language == p.Language +} + // xmltvTimeToISO converts "20250225200000 +0000" → "2025-02-25T20:00:00Z" func xmltvTimeToISO(xmltvTime string) string { xmltvTime = strings.TrimSpace(xmltvTime) @@ -607,6 +688,24 @@ func handleGuideJSON(state *GuideState) http.HandlerFunc { } } +func handleLineupJSON(state *GuideState, store *appconfig.Store) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + g := state.Get() + if g == nil { + w.Header().Set("Retry-After", "30") + http.Error(w, "Guide is being generated", http.StatusServiceUnavailable) + return + } + + config, configured, _ := store.Get() + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Origin", "*") + enc := json.NewEncoder(w) + enc.SetEscapeHTML(false) + enc.Encode(lineupToJSON(g, config, configured)) + } +} + // ---------- Image proxy ---------- const imageCacheDir = "image_cache" @@ -1115,6 +1214,7 @@ func main() { mux.HandleFunc("/api/setup/status", setupHandlers.handleScrapeStatus) mux.HandleFunc("/xmlguide.xmltv", handleXMLTV(state)) mux.HandleFunc("/api/guide.json", handleGuideJSON(state)) + mux.HandleFunc("/api/lineup.json", handleLineupJSON(state, configStore)) mux.HandleFunc("/img", handleImage) mux.HandleFunc("/api/livetv/config", handleLiveTVConfig(jellyfinURL, jellyfinAPIKey)) if jellyfinURL != "" && jellyfinAPIKey != "" {