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
127 changes: 114 additions & 13 deletions guide/guide.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,112 @@ package guide

import (
"fmt"
"sort"
"strconv"
"strings"
"time"
"unicode"

"github.com/daniel-widrick/GraceNoteScraper/web"
)

type TVGuide struct {
// Channels is the XMLTV view: one entry per Gracenote station.
Channels []Channel
Programs []Program
// Lineup retains every provider position, so a station carried at two
// channel numbers appears twice. It is never collapsed.
Lineup []LineupPosition
// Source records which provider lineup produced this guide.
Source Source
}

// Source identifies the Gracenote lineup a guide was built from.
type Source struct {
Country string
PostalCode string
HeadendID string
LineupID string
Device string
Language string
GeneratedAt time.Time
}

// SourceFromPreferences copies the request preferences into a Source.
func SourceFromPreferences(p web.Preferences, generatedAt time.Time) Source {
return Source{
Country: p.Country,
PostalCode: p.ZipCode,
HeadendID: p.Headend,
LineupID: p.LineupId,
Device: p.Device,
Language: p.Language,
GeneratedAt: generatedAt,
}
}

// LineupPosition is one channel number in a provider lineup.
type LineupPosition struct {
ChannelNo string
StationID string
PlacementID string // Gracenote row id; carried for fidelity, not a stable key
CallSign string
Affiliate string
AffiliateCallSign string
Filters []string
LogoURL string
}

// Key identifies a position across grid slices: the same station at the same
// number is one position no matter how many responses it appears in.
func (p LineupPosition) Key() string {
return p.ChannelNo + "|" + p.StationID
}

// ConvertLineupPosition converts a JSON channel row to a lineup position.
func ConvertLineupPosition(ch web.JSONChannel) LineupPosition {
return LineupPosition{
ChannelNo: ch.ChannelNo,
StationID: ch.ChannelID,
PlacementID: ch.ID,
CallSign: ch.CallSign,
Affiliate: ch.AffiliateName,
AffiliateCallSign: normalizeNull(ch.AffiliateCallSign),
Filters: stripFilterPrefixes(ch.StationFilters),
LogoURL: gracenoteIconURL(ch.Thumbnail),
}
}

// ChannelNumberLess orders channel numbers numerically where both parse
// (so "2.1" < "10" < "100"), places numeric numbers before non-numeric ones,
// and falls back to string order. Equal numbers compare as strings so the
// ordering is strict.
func ChannelNumberLess(a, b string) bool {
af, errA := strconv.ParseFloat(strings.TrimSpace(a), 64)
bf, errB := strconv.ParseFloat(strings.TrimSpace(b), 64)
switch {
case errA == nil && errB == nil:
if af != bf {
return af < bf
}
return a < b
case errA == nil:
return true
case errB == nil:
return false
default:
return a < b
}
}

// SortLineup orders positions by channel number, then station ID, in place.
func SortLineup(positions []LineupPosition) {
sort.SliceStable(positions, func(i, j int) bool {
if positions[i].ChannelNo != positions[j].ChannelNo {
return ChannelNumberLess(positions[i].ChannelNo, positions[j].ChannelNo)
}
return positions[i].StationID < positions[j].StationID
})
}

type Channel struct {
Expand Down Expand Up @@ -102,21 +198,26 @@ func formatXMLTVTime(iso string) string {
return s
}

// gracenoteIconURL builds an absolute icon URL from a Gracenote thumbnail
// path: strip leading slashes, strip query params, prepend http://
func gracenoteIconURL(thumbnail string) string {
if thumbnail == "" {
return ""
}
raw := thumbnail
if idx := strings.Index(raw, "?"); idx >= 0 {
raw = raw[:idx]
}
raw = strings.TrimLeft(raw, "/")
if raw == "" {
return ""
}
return "http://" + raw
}

// converts a JSON channel to a template Channel struct.
func ConvertChannel(ch web.JSONChannel) Channel {
// Build icon URL: strip leading slashes, strip query params, prepend http://
iconURL := ""
if ch.Thumbnail != "" {
raw := ch.Thumbnail
// Strip query string
if idx := strings.Index(raw, "?"); idx >= 0 {
raw = raw[:idx]
}
raw = strings.TrimLeft(raw, "/")
if raw != "" {
iconURL = "http://" + raw
}
}
iconURL := gracenoteIconURL(ch.Thumbnail)

return Channel{
ID: ch.ChannelID,
Expand Down
82 changes: 82 additions & 0 deletions guide/guide_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package guide
import (
"reflect"
"testing"
"time"

"github.com/daniel-widrick/GraceNoteScraper/web"
)
Expand Down Expand Up @@ -113,3 +114,84 @@ func TestConvertEventWithoutFilters(t *testing.T) {
t.Errorf("Categories should be empty, got %v", p.Categories)
}
}

func TestConvertLineupPosition(t *testing.T) {
in := sampleChannel()
p := ConvertLineupPosition(in)
want := LineupPosition{
ChannelNo: "2.1",
StationID: "53158",
PlacementID: "531580",
CallSign: "WKTVDT",
Affiliate: "NATIONAL BROADCASTING COMPANY",
AffiliateCallSign: "",
Filters: []string{"sports", "news"},
LogoURL: "http://images.example.invalid/station/53158.png",
}
if !reflect.DeepEqual(p, want) {
t.Fatalf("got %+v\nwant %+v", p, want)
}
if p.Key() != "2.1|53158" {
t.Errorf("Key = %q", p.Key())
}
other := in
other.ChannelNo = "1002"
other.ID = "5315899"
if ConvertLineupPosition(other).Key() == p.Key() {
t.Error("same station at a different number must be a distinct position")
}
}

func TestChannelNumberLess(t *testing.T) {
cases := []struct {
a, b string
want bool
}{
{"2.1", "10", true},
{"10", "2.1", false},
{"10", "100", true},
{"9", "10", true},
{"2.1", "2.10", true}, // equal numerically, string order breaks the tie
{"2.10", "2.1", false},
{"5", "5", false},
{"12", "ABC", true}, // numeric before non-numeric
{"ABC", "12", false},
{"ABC", "ABD", true},
}
for _, c := range cases {
if got := ChannelNumberLess(c.a, c.b); got != c.want {
t.Errorf("ChannelNumberLess(%q, %q) = %v, want %v", c.a, c.b, got, c.want)
}
}
}

func TestSortLineup(t *testing.T) {
positions := []LineupPosition{
{ChannelNo: "100", StationID: "b"},
{ChannelNo: "ZZ", StationID: "z"},
{ChannelNo: "2.1", StationID: "a"},
{ChannelNo: "10", StationID: "c"},
{ChannelNo: "10", StationID: "a"},
{ChannelNo: "9", StationID: "d"},
}
SortLineup(positions)
var got []string
for _, p := range positions {
got = append(got, p.ChannelNo+"/"+p.StationID)
}
want := []string{"2.1/a", "9/d", "10/a", "10/c", "100/b", "ZZ/z"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("order = %v, want %v", got, want)
}
}

func TestSourceFromPreferences(t *testing.T) {
at := time.Date(2026, 9, 13, 0, 0, 0, 0, time.UTC)
src := SourceFromPreferences(web.Preferences{
Country: "USA", ZipCode: "13490", Headend: "lineupId", LineupId: "USA-lineupId-DEFAULT", Device: "-", Language: "en-us",
}, at)
want := Source{Country: "USA", PostalCode: "13490", HeadendID: "lineupId", LineupID: "USA-lineupId-DEFAULT", Device: "-", Language: "en-us", GeneratedAt: at}
if src != want {
t.Fatalf("got %+v want %+v", src, want)
}
}
85 changes: 85 additions & 0 deletions lineup_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package main

import (
"net/url"
"reflect"
"testing"

"github.com/daniel-widrick/GraceNoteScraper/guide"
)

func sampleLineupGuide() *guide.TVGuide {
return &guide.TVGuide{
Channels: []guide.Channel{
{ID: "s1", ChannelNo: "2", IconURL: "http://logos.example/s1.png", DisplayNames: []guide.DisplayName{{Name: "2 AAA"}, {Name: "2"}, {Name: "AAA"}}},
{ID: "s2", ChannelNo: "5", IconURL: "", DisplayNames: []guide.DisplayName{{Name: "5 BBB"}, {Name: "5"}, {Name: "BBB"}}},
},
Programs: []guide.Program{
{Channel: "s1", Title: "one", IconSrc: "http://img.example/a.jpg", Images: []guide.Image{{URL: "http://img.example/b.jpg"}}},
{Channel: "s2", Title: "two"},
},
Lineup: []guide.LineupPosition{
{ChannelNo: "2", StationID: "s1"},
{ChannelNo: "5", StationID: "s2"},
{ChannelNo: "1002", StationID: "s1"}, // same station, second position
},
Source: guide.Source{LineupID: "L"},
}
}

func TestPropagateChannelLogosCopiesByStation(t *testing.T) {
g := sampleLineupGuide()
propagateChannelLogos(g.Channels, g.Lineup)

if g.Lineup[0].LogoURL != "http://logos.example/s1.png" || g.Lineup[2].LogoURL != "http://logos.example/s1.png" {
t.Fatalf("both positions of s1 should carry its logo: %+v", g.Lineup)
}
if g.Lineup[1].LogoURL != "" {
t.Fatalf("s2 has no logo, got %q", g.Lineup[1].LogoURL)
}
}

func TestRewriteImageURLsCoversLineup(t *testing.T) {
g := sampleLineupGuide()
propagateChannelLogos(g.Channels, g.Lineup)
rewriteImageURLs("http://host:8080/", g.Channels, g.Lineup, g.Programs)

want := "http://host:8080/img?url=" + url.QueryEscape("http://logos.example/s1.png")
if g.Channels[0].IconURL != want {
t.Errorf("channel icon = %q, want %q", g.Channels[0].IconURL, want)
}
if g.Lineup[0].LogoURL != want || g.Lineup[2].LogoURL != want {
t.Errorf("lineup logos = %q / %q, want %q", g.Lineup[0].LogoURL, g.Lineup[2].LogoURL, want)
}
if g.Lineup[1].LogoURL != "" || g.Channels[1].IconURL != "" {
t.Error("empty URLs must stay empty")
}
if g.Programs[0].IconSrc != "http://host:8080/img?url="+url.QueryEscape("http://img.example/a.jpg") {
t.Errorf("program icon = %q", g.Programs[0].IconSrc)
}
if g.Programs[0].Images[0].URL != "http://host:8080/img?url="+url.QueryEscape("http://img.example/b.jpg") {
t.Errorf("program image = %q", g.Programs[0].Images[0].URL)
}
}

func TestFilterGuideChannelsFiltersLineup(t *testing.T) {
g := sampleLineupGuide()
filtered := filterGuideChannels(g, map[string]bool{"2": true, "1002": true})

if len(filtered.Channels) != 1 || filtered.Channels[0].ID != "s1" {
t.Fatalf("channels = %+v", filtered.Channels)
}
if len(filtered.Programs) != 1 || filtered.Programs[0].Title != "one" {
t.Fatalf("programs = %+v", filtered.Programs)
}
var numbers []string
for _, p := range filtered.Lineup {
numbers = append(numbers, p.ChannelNo)
}
if !reflect.DeepEqual(numbers, []string{"2", "1002"}) {
t.Fatalf("lineup numbers = %v", numbers)
}
if filtered.Source != g.Source {
t.Fatal("source not carried through filter")
}
}
Loading
Loading