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 != "" {