Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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) |
Expand Down
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,13 +118,49 @@ 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 |
| `GET /api/livetv/channels` | Proxies Jellyfin channel list (requires `JELLYFIN_URL` and `JELLYFIN_API_KEY`) |
| `GET /api/livetv/tune?id=<channelId>` | 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.
Expand Down
195 changes: 195 additions & 0 deletions lineup_handler_test.go
Original file line number Diff line number Diff line change
@@ -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
}
100 changes: 100 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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 != "" {
Expand Down
Loading