From 4c4563bbc47788638422defcd0116f72dc92c498 Mon Sep 17 00:00:00 2001 From: form400 <155935848+form400@users.noreply.github.com> Date: Sat, 16 May 2026 23:34:10 -0500 Subject: [PATCH 1/5] improve tv-logo channel matching rate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add local affiliate pattern: {network}-{channelNo}-{callsign} and {network}-{callsign} - Fix slugify: & converts to "and" instead of "-" (fixes A&E, AT&T SportsNet, etc.) - Fix weather channel alias: "the-weather-channel" → "weather-channel" - Correct noiseWords: stop stripping "network", "channel", "tv", "entertainment" - Add networkSlugs map for ABC/CBS/NBC/FOX/CW/PBS/Telemundo/Univision local affiliates - Add bareCallSign: strips both dash-separated (-TV, -DT, -HD) and inline (HD, DT) suffixes - Reorder candidates: alias → full slug → local affiliate patterns → bare callsign → fallbacks - Thread ChannelNo through Channel struct and Resolve() for affiliate pattern generation Co-Authored-By: Claude Sonnet 4.6 --- guide/guide.go | 2 + main.go | 2 +- tvlogo/tvlogo.go | 135 +++++++++++++++++++++++++++++++---------------- 3 files changed, 92 insertions(+), 47 deletions(-) diff --git a/guide/guide.go b/guide/guide.go index 1aa5aba..821f5f7 100644 --- a/guide/guide.go +++ b/guide/guide.go @@ -20,6 +20,7 @@ type Channel struct { IconURL string CallSign string // internal, not in template Affiliate string // internal, not in template + ChannelNo string // internal, not in template } type DisplayName struct { @@ -121,6 +122,7 @@ func ConvertChannel(ch web.JSONChannel) Channel { IconURL: iconURL, CallSign: ch.CallSign, Affiliate: ch.AffiliateName, + ChannelNo: ch.ChannelNo, } } diff --git a/main.go b/main.go index c4bae26..90784ac 100644 --- a/main.go +++ b/main.go @@ -1093,7 +1093,7 @@ func enrichChannelIcons(client *tvlogo.Client, channels []guide.Channel) { enriched := 0 for i := range channels { - logoURL := client.Resolve(channels[i].ID, channels[i].CallSign, channels[i].Affiliate) + logoURL := client.Resolve(channels[i].ID, channels[i].CallSign, channels[i].Affiliate, channels[i].ChannelNo) if logoURL != "" { channels[i].IconURL = logoURL enriched++ diff --git a/tvlogo/tvlogo.go b/tvlogo/tvlogo.go index 7329c7a..c8e79e9 100644 --- a/tvlogo/tvlogo.go +++ b/tvlogo/tvlogo.go @@ -28,38 +28,69 @@ var countryMap = map[string]countryInfo{ // when algorithmic normalization wouldn't produce the right slug. var affiliateAliases = map[string]string{ "home box office": "hbo", - "national broadcasting company": "nbc", - "american broadcasting company": "abc", - "cbs television network": "cbs", - "fox entertainment": "fox", - "fox broadcasting": "fox", - "fox broadcasting company": "fox", - "turner network television": "tnt", + "national broadcasting company": "nbc", + "american broadcasting company": "abc", + "cbs television network": "cbs", + "fox entertainment": "fox", + "fox broadcasting": "fox", + "fox broadcasting company": "fox", + "turner network television": "tnt", "entertainment and sports programming network": "espn", - "cable news network": "cnn", - "the weather channel": "the-weather-channel", - "comedy central": "comedy-central", - "cartoon network": "cartoon-network", - "animal planet": "animal-planet", - "public broadcasting service": "pbs", - "cable-satellite public affairs network": "c-span", + "cable news network": "cnn", + "the weather channel": "weather-channel", + "comedy central": "comedy-central", + "cartoon network": "cartoon-network", + "animal planet": "animal-planet", + "public broadcasting service": "pbs", + "cable-satellite public affairs network": "c-span", + "turner classic movies": "tcm", + "american movie classics": "amc", + "freeform": "freeform", + "fx networks": "fx", + "investigation discovery": "investigation-discovery", + "oprah winfrey network": "oprah-winfrey-network", + "a and e": "a-and-e", + "a&e": "a-and-e", +} + +// networkSlugs maps affiliate name variations to their short network slug, +// used to generate {network}-{number}-{callsign} patterns for local affiliates. +var networkSlugs = map[string]string{ + "abc": "abc", + "american broadcasting company": "abc", + "cbs": "cbs", + "cbs television network": "cbs", + "nbc": "nbc", + "national broadcasting company": "nbc", + "fox": "fox", + "fox broadcasting": "fox", + "fox broadcasting company": "fox", + "fox entertainment": "fox", + "the cw": "cw", + "cw": "cw", + "pbs": "pbs", + "public broadcasting service": "pbs", + "telemundo": "telemundo", + "univision": "univision", + "unimas": "unimas", + "my network tv": "my-network-tv", } // noiseWords are stripped from affiliate names during normalization. +// Notably excludes "channel", "network", and "tv" since many repo slugs include those words. var noiseWords = map[string]bool{ - "television": true, - "network": true, - "channel": true, - "broadcasting": true, - "company": true, - "entertainment": true, - "corporation": true, - "inc": true, + "broadcasting": true, + "company": true, + "corporation": true, + "inc": true, } -// matches common HD/SD/DT suffixes on callsigns. +// matches common HD/SD/DT suffixes on callsigns (inline, e.g. "ESPNHD"). var hdSuffixRe = regexp.MustCompile(`(?i)(hd|sd|dt|hd2|hd3|hd4)$`) +// matches dash-separated station suffixes like -TV, -DT, -HD, -LD, -DT2. +var dashSuffixRe = regexp.MustCompile(`(?i)-(tv|dt|hd|ld|dt2|hd2|hd3)$`) + // helps split compound callsigns like "ESPNHD" → "espn". var knownPrefixes = []string{ "espn", "fox", "hbo", "cnn", "tbs", "tnt", "usa", "amc", @@ -101,7 +132,7 @@ func (c *Client) Close() { // returns a verified logo URL for the channel, or "" if none found. // Results are cached by channel ID. -func (c *Client) Resolve(channelID, callSign, affiliateName string) string { +func (c *Client) Resolve(channelID, callSign, affiliateName, channelNo string) string { if c == nil { return "" } @@ -111,7 +142,7 @@ func (c *Client) Resolve(channelID, callSign, affiliateName string) string { return entry.LogoURL } - candidates := c.generateCandidates(callSign, affiliateName) + candidates := c.generateCandidates(callSign, affiliateName, channelNo) logoURL := "" for _, slug := range candidates { @@ -127,7 +158,7 @@ func (c *Client) Resolve(channelID, callSign, affiliateName string) string { } // returns an ordered list of slugs to try. -func (c *Client) generateCandidates(callSign, affiliateName string) []string { +func (c *Client) generateCandidates(callSign, affiliateName, channelNo string) []string { seen := make(map[string]bool) var candidates []string @@ -141,19 +172,37 @@ func (c *Client) generateCandidates(callSign, affiliateName string) []string { affiliate := strings.ToLower(strings.TrimSpace(affiliateName)) call := strings.ToLower(strings.TrimSpace(callSign)) - // Check alias table first + // 1. Check alias table first (handles long-form and irregular names) if alias, ok := affiliateAliases[affiliate]; ok { add(alias) } - // Normalized affiliate name (strip noise words) - add(normalizeAffiliate(affiliate)) + // 2. Full affiliate slug — no stripping; matches "action-channel", "history-channel", etc. + add(slugify(affiliate)) + + // 3. {network}-{channelNo}-{callsign} — matches local affiliate logos like "abc-7-kabc" + bare := bareCallSign(call) + if network, ok := networkSlugs[affiliate]; ok && bare != "" { + if channelNo != "" { + add(network + "-" + channelNo + "-" + bare) + } + // 4. {network}-{callsign} — matches "abc-kota", "nbc-kdlt", "fox-wjzy", etc. + add(network + "-" + bare) + } - // Normalized callsign (strip HD/SD/DT suffixes, try known-prefix split) - stripped := stripCallSignSuffix(call) - add(stripped) + // 5. Bare callsign alone — matches standalone entries like "wjxt", "wlny" + add(bare) - // Try known-prefix extraction for compound callsigns + // 6. Affiliate without leading "the" — "The Weather Channel" → "weather-channel" + if strings.HasPrefix(affiliate, "the ") { + add(slugify(strings.TrimPrefix(affiliate, "the "))) + } + + // 7. Normalized affiliate (strip noise words) — fallback for unusual long-form names + add(normalizeAffiliate(affiliate)) + + // 8. Known-prefix extraction for compound callsigns like "ESPNHD" → "espn" + stripped := hdSuffixRe.ReplaceAllString(call, "") for _, prefix := range knownPrefixes { if strings.HasPrefix(stripped, prefix) && len(stripped) > len(prefix) { add(prefix) @@ -161,14 +210,6 @@ func (c *Client) generateCandidates(callSign, affiliateName string) []string { } } - // Full affiliate name as slug (without stripping noise words) - add(slugify(affiliate)) - - // Raw lowered callsign (suffix-stripped only) - if stripped != call { - add(call) // also try the raw form with suffix - } - return candidates } @@ -184,15 +225,17 @@ func normalizeAffiliate(name string) string { return slugify(strings.Join(kept, " ")) } -// removes HD/SD/DT suffixes from callsigns. -func stripCallSignSuffix(call string) string { - return hdSuffixRe.ReplaceAllString(call, "") +// returns the bare callsign with dash-separated and inline HD/SD/DT suffixes removed. +func bareCallSign(call string) string { + s := dashSuffixRe.ReplaceAllString(call, "") + s = hdSuffixRe.ReplaceAllString(s, "") + return strings.Trim(s, "- ") } -// converts a name to a URL-safe slug: lowercase, spaces/punctuation to hyphens. +// converts a name to a URL-safe slug: lowercase, & → "and", spaces/punctuation to hyphens. func slugify(s string) string { s = strings.ToLower(s) - // Replace non-alphanumeric with hyphens + s = strings.ReplaceAll(s, "&", " and ") var b strings.Builder prev := false for _, r := range s { From 1393cd7a297cd0a3cec4b2bdb64a8c2b59bf74fe Mon Sep 17 00:00:00 2001 From: form400 <155935848+form400@users.noreply.github.com> Date: Sun, 17 May 2026 00:02:15 -0500 Subject: [PATCH 2/5] add CLAUDE.md with build commands and architecture overview Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..41f7c8d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,54 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +```sh +# Build +go build -o gracenotescraper . + +# Run (server mode, port 8080) +./gracenotescraper + +# Run (scrape once and exit — no server) +./gracenotescraper --guide-only + +# Docker +docker compose up -d +docker compose logs -f +docker compose up -d --build + +# Release builds (CGO disabled, trimmed) +GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o gracenotescraper . +``` + +There are no test files in this project. + +## Architecture + +The binary is a single Go process that scrapes GraceNote/TMS for 14 days of TV listings and serves the data as XMLTV over HTTP. The main orchestration lives entirely in `main.go`. + +**Data flow:** + +1. `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`. +2. `guide.ConvertChannel` / `guide.ConvertEvent` translate the raw JSON into `guide.TVGuide` (internal canonical types). The `guide.tmpl` template renders these to XMLTV. Both `index.html` and `guide.tmpl` are embedded at build time via `//go:embed`. +3. `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. +4. `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). +5. `fixDeadImageURLs` rewrites `zap2it.tmsimg.com` → `tmsimg.com` for broken Gracenote image URLs. +6. If `BASE_URL` is set, all image URLs are rewritten to route through the local `/img` proxy endpoint. + +**Caching layers:** + +| Cache | File | TTL | +|---|---|---| +| Guide (in-memory + disk) | `guide_cache.json` | 4h (startup skip) / 24h (rescrape) | +| 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) | + +**Server mode startup logic:** On start, if `xmlguide.xmltv` and `guide_cache.json` both exist and the cache is under 4 hours old, the scrape is skipped. The next scrape fires when the cache would have been 24 hours old. A `sync.RWMutex`-guarded `GuideState` struct holds the live `*guide.TVGuide` and is swapped atomically after each background scrape. + +**Jellyfin integration:** Optional. When `JELLYFIN_URL` + `JELLYFIN_API_KEY` are set, three extra routes are registered (`/api/livetv/channels`, `/api/livetv/tune`, `/api/livetv/stop`). The tune flow does a 3-step Jellyfin handshake (PlaybackInfo → LiveStreams/Open → build master.m3u8 URL) with a hardcoded 4-second delay before returning the HLS URL. Channel filter (`JELLYFIN_CHANNEL_FILTER`) reduces the guide to only channels Jellyfin has in its live TV lineup, matched by channel number. + +**Image proxy allowlist:** Only `image.tmdb.org`, `tmsimg.com`, and `raw.githubusercontent.com/tv-logo/tv-logos/` paths are proxied. All other URLs return 403. From ff88d5a9126ea8fa5416ceef5788e8b85cf7fde7 Mon Sep 17 00:00:00 2001 From: form400 <155935848+form400@users.noreply.github.com> Date: Sun, 17 May 2026 15:06:55 -0500 Subject: [PATCH 3/5] add callsign abbreviation map for cryptic GraceNote callsigns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GraceNote returns empty affiliateName for ~93% of cable channels and provides only a cryptic callsign (e.g., "HISTORY", "TOON", "PAR", "STZENCL"). Direct slugification produced "history", "toon", "par" — none of which match the actual repo slugs like "history-channel-us.png". - Add callsignSlugs map (280 entries) covering cable networks, premium movie families (Starz/MGM+/Cinemax/Showtime/TMC), sports, news, kids, Spanish-language, and shopping channels. Lookup runs against both the raw callsign and its suffix-stripped form. - Extend hdSuffixRe to strip HDP, HP, and standalone P (Plus variants). - Iterate suffix stripping so compound suffixes collapse fully (MAXHDP -> MAXHD -> MAX). - Add matcherVersion constant + MatcherVersion field on CacheEntry. Cache.Get() invalidates failed entries from older versions so logic improvements take effect without a manual cache wipe; successful matches are preserved across versions. Projected impact on a 616-channel lineup with 93% empty-affiliate cable channels: 23.0% -> 67.2% match rate (272 additional channels). Co-Authored-By: Claude Opus 4.7 --- tvlogo/cache.go | 12 +- tvlogo/tvlogo.go | 386 +++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 381 insertions(+), 17 deletions(-) diff --git a/tvlogo/cache.go b/tvlogo/cache.go index 5f28313..54ef943 100644 --- a/tvlogo/cache.go +++ b/tvlogo/cache.go @@ -11,8 +11,9 @@ import ( const cacheTTL = 30 * 24 * time.Hour type CacheEntry struct { - LogoURL string `json:"logo_url"` - FetchedAt int64 `json:"fetched_at"` + LogoURL string `json:"logo_url"` + FetchedAt int64 `json:"fetched_at"` + MatcherVersion int `json:"matcher_version,omitempty"` } type Cache struct { @@ -51,6 +52,12 @@ func (c *Cache) Get(key string) (CacheEntry, bool) { delete(c.entries, key) return CacheEntry{}, false } + // Retry failures from older matcher versions so logic improvements take effect + // without a manual cache wipe. Successful matches are preserved across versions. + if entry.MatcherVersion < matcherVersion && entry.LogoURL == "" { + delete(c.entries, key) + return CacheEntry{}, false + } return entry, true } @@ -59,6 +66,7 @@ func (c *Cache) Set(key string, entry CacheEntry) { defer c.mu.Unlock() entry.FetchedAt = time.Now().Unix() + entry.MatcherVersion = matcherVersion c.entries[key] = entry } diff --git a/tvlogo/tvlogo.go b/tvlogo/tvlogo.go index c8e79e9..5699185 100644 --- a/tvlogo/tvlogo.go +++ b/tvlogo/tvlogo.go @@ -85,12 +85,349 @@ var noiseWords = map[string]bool{ "inc": true, } -// matches common HD/SD/DT suffixes on callsigns (inline, e.g. "ESPNHD"). -var hdSuffixRe = regexp.MustCompile(`(?i)(hd|sd|dt|hd2|hd3|hd4)$`) +// matches common HD/SD/DT/Plus suffixes on callsigns (inline, e.g. "MAXHDP", "TOONP"). +var hdSuffixRe = regexp.MustCompile(`(?i)(hdp|hp|hd[234]|hd|sd|dt2|dt|p)$`) // matches dash-separated station suffixes like -TV, -DT, -HD, -LD, -DT2. var dashSuffixRe = regexp.MustCompile(`(?i)-(tv|dt|hd|ld|dt2|hd2|hd3)$`) +// Bump when matching logic changes. Cache entries below this version with +// empty results are re-checked on next access (matched entries are preserved). +const matcherVersion = 2 + +// callsignSlugs maps cryptic GraceNote callsign abbreviations to tv-logo repo slugs. +// Many providers send these short callsigns with NO affiliate name, so direct +// slugification fails (e.g., "HISTORY" never matches "history-channel-us.png"). +// Keys must be lowercase. Lookup is tried against both the raw and suffix-stripped +// callsign, so entries here should be the suffix-stripped form when applicable. +var callsignSlugs = map[string]string{ + // A&E / History / Discovery family + "aetv": "a-and-e", + "ahc": "american-heroes-channel", + "apl": "animal-planet", + "history": "history-channel", + "hstry": "history-channel", + "histe": "history-en-espanol", + "dsc": "discovery-channel", + "dsce": "discovery-en-espanol", + "dfam": "discovery-family", + "dfc": "discovery-family", + "dlc": "discovery-life", + "dest": "destination-america", + "science": "discovery-science", + "sci": "discovery-science", + "id": "investigation-discovery", + "idtv": "investigation-discovery", + // Disney family + "disn": "disney-channel", + "dsn": "disney-channel", + "dxd": "disney-xd", + "djch": "disney-jr", + "djr": "disney-jr", + // BBC / News + "bbca": "bbc-america", + "bbcaus": "bbc-america", + "cnbc": "cnbc", + "fnc": "fox-news", + "fbn": "fox-business", + "hln": "hln", + "msnow": "ms-now", + "msnbc": "msnbc-alt", + "newsmx": "newsmax-tv", + "nwsntn": "news-nation", + "newsntn": "news-nation", + "nwsnt": "news-nation", + // BET / Music + "bet": "bet", + "bher": "bet-her", + "mtv": "mtv", + "mtv2": "mtv-2", + "vh1": "vh1", + "cmtv": "cmt", + "cmtmus": "cmt-music", + "revolt": "revolt", + "rvlt": "revolt", + "fuse": "fuse", + // Sports networks + "bigten": "big-ten-network", + "big10": "big-ten-network", + "btn": "btn", + "sec": "sec-network", + "secn": "sec-network", + "secnp": "sec-network", + "acc": "espn-accn", + "accn": "espn-accn", + "cbssn": "cbs-sports-network", + "fs1": "fox-sports-1", + "fs2": "fox-sports-2", + "fxdep": "fox-sports-deportes", + "golf": "nbc-golf", + "nbcgolf": "nbc-golf", + "gsn": "game-show-network", + "marq": "marquee-sports-network", + "mlbn": "mlb-network", + "mlb": "mlb-network", + "mlbsz": "mlb-network-strike-zone", + "nbatv": "nba-tv", + "nba": "nba-tv", + "nhlnet": "nhl-network", + "nhl": "nhl-network", + "nflnet": "nfl-network", + "nfl": "nfl-network", + "nflnrz": "nfl-red-zone", + "redzone": "nfl-red-zone", + "tennis": "tennis-channel", + "tenis": "tennis-channel", + "snla": "spectrum-sportsnet-la", + "specsn": "spectrum-sportsnet", + "willow": "willow", + "sprtman": "sportsman-channel", + "cowboy": "cowboy-channel", + "rfdtv": "rfd-tv", + "outd": "outdoor-channel", + "out": "outdoor-channel", + "racer": "racer-network", + "pursuit": "pursuit", + "purst": "pursuit", + "fduel": "fanduel-tv", + "fdueltv": "fanduel-tv", + "fduelrc": "fanduel-racing", + // Bloomberg / Misc news + "bloom": "bloomberg-television", + "cnbcwld": "cnbc-world-flat", + // Cartoon / Kids + "boom": "boomerang", + "toon": "cartoon-network", + "toonp": "cartoon-network", + "tnck": "teen-nick", + "nik": "nick", + "nicjr": "nick-jr", + "nikton": "nick-toons", + "niktn": "nick-toons", + // Religious / Inspirational + "byutv": "byu-tv", + "kdtx": "tbn", + "tbn": "tbn", + "sbn": "sbn", + "insp": "insp", + "daystar": "daystar", + // Cars / Specialty + "carstv": "cars-tv", + "mt": "motor-trend", + "mthd": "motor-trend", + // Cinemax (base) + "cmax": "cinemax", + "max": "cinemax", + "cin": "cinemax", + "cinr": "cinemax", + "cinhd": "cinemax", + "cinlus": "cinemax-en-espanol", + "cinact": "cinemax-action", + "cinacht": "cinemax-action", + "cinach": "cinemax-action", + "cinacsp": "cinemax-en-espanol", + "cinecls": "cinemax-classics", + "cincl": "cinemax-classics", + "cinehit": "cinemax-hits", + "cinehp": "cinemax-hits", + "cinehh": "cinemax-hits", + "cineh": "cinemax-hits", + "cinhh": "cinemax-hits", + "cinenos": "cinemax-outermax", + "cineste": "cinemax-thrillermax", + "cinete": "cinemax-thrillermax", + // Comedy / Cooking / Lifestyle + "comedy": "comedy-central", + "cmdytv": "comedy-central", + "cmdtv": "comedy-central", + "cook": "cooking-channel", + "cozitv": "cozi-tv", + "food": "food-network", + "hgtv": "hgtv", + "magn": "magnolia-network", + "recipe": "recipe-tv", + "retro": "retro-tv", + "shorts": "shorts-tv", + "shoplc": "shop-lc", + "gems": "gem-shopping-network", + "qvc": "qvc", + "qvc2": "qvc-2", + "qvc3": "qvc-3", + "hsn": "hsn", + "hsn2": "hsn-2", + // C-SPAN + "cspan": "c-span-1", + "cspan1": "c-span-1", + "cspan2": "c-span-2", + "cspan3": "c-span-3", + // E! / ET + "e": "e-entertainment", + "etstv": "et-live", + "etnew": "et-live", + // Freeform / Family + "freefrm": "freeform", + "frefm": "freeform", + "freefm": "freeform", + // Fox / FX + "fx": "fx", + "fxx": "fxx", + "fxm": "fxm-movie-channel", + "fyi": "fyi", + "fmc": "fmc-family-movie-classics", + // Spanish/Latin + "gala": "galavision", + "telemundo": "telemundo", + "telen": "telemundo", + "unimas": "unimas", + "univision": "univision", + "unvso": "nbc-universo", + "tudn": "tudn", + "tudnu": "tudn", + "tr3s": "tres", + // Hallmark + "hall": "hallmark-channel", + "hmys": "hallmark-mystery", + "hallm": "hallmark-movies-now", + // Heroes & Icons + "hericns": "heroes-and-icons", + "heroicn": "heroes-and-icons", + // IFC / Indie + "ifc": "ifc", + // ION + "kpxd": "ion-television", + "ion": "ion-television", + // Laff / Bounce / Grit / Court + "laff": "laff", + "bounce": "bounce", + "grit": "grit", + "court": "court-tv", + // Lifetime + "life": "lifetime", + "lmn": "lifetime-movie-network", + "lrw": "lifetime-real-women", + "women": "lifetime-real-women", + // MGM+ + "mgm": "mgm-plus", + "mgmhit": "mgm-plus-hits", + "mgmhth": "mgm-plus-hits", + "mgmdrv": "mgm-plus-drive-in", + "mgmmr": "mgm-plus-marquee", + "mgmw": "mgm-plus", + // Military + "mil": "military-history", + // Me-TV / MyNetwork + "metvn": "me-tv", + "metv": "me-tv", + "mnnt": "my-network-tv", + "mynet": "my-network-tv", + // Movie Plex + "mplex": "movie-plex", + // National Geographic + "ngc": "national-geographic", + "ngmundo": "nat-geo-mundo", + "ngwild": "nat-geo-wild", + "ngwi": "nat-geo-wild", + // Ovation / OWN / Oxygen + "ovatn": "ovation", + "own": "oprah-winfrey-network", + "oxy": "oxygen", + "oxygn": "oxygen", + // Paramount + "par": "paramount-network", + "parsho": "paramount-plus-with-showtime", + "parshow": "paramount-plus-with-showtime", + "paramount": "paramount-plus", + // Pets / Misc + "petstv": "pets-tv", + "playboy": "playboy-tv", + "play": "playboy-tv", + "positiv": "positiv", + "postv": "positiv", + "pop": "pop", + // Showtime + "sho": "showtime", + "sho2": "showtime-2", + "shocse": "showtime-showcase", + "shocs": "showtime-showcase", + "showx": "showtime-extreme", + "shobet": "sho-bet", + "shobeth": "sho-bet", + "szeb": "showtime-beyond", + "szesu": "showtime-showcase", + // Smithsonian + "smith": "smithsonian-channel", + "smth": "smithsonian-channel", + "schn": "smithsonian-channel", + // Sony / Movie + "sony": "sony-movie-channel", + "sonyhd": "sony-movie-channel", + // Starz family + "stz": "starz", + "stze": "starz-edge", + "stzk": "starz-kids-and-family", + "stzc": "starz-cinema", + "stzib": "starz-in-black", + "strzib": "starz-in-black", + "stzesp": "starz-encore-espanol", + "stzenc": "starz-encore", + "stzenbk": "starz-encore-black", + "stzenac": "starz-encore-action", + "stzensu": "starz-encore-suspense", + "stzencl": "starz-encore-classic", + "stzenws": "starz-encore-westerns", + "stzenfm": "starz-encore-family", + // Sundance + "sundanc": "sundance-tv", + "sundance": "sundance-tv", + "sund": "sundance-tv", + // TBS / TCM / TBN / TLC / TNT + "tbs": "tbs", + "tcm": "tcm", + "tlc": "tlc", + "tnt": "tnt", + // TMC (The Movie Channel) + "tmc": "the-movie-channel", + "tmcx": "the-movie-channel-xtra", + // Travel + "trav": "travel-channel", + "travel": "travel-channel", + // TruTV + "trutv": "tru-tv", + // TV Land + "tvland": "tv-land", + "tvlnd": "tv-land", + // Up / USA + "up": "up-tv", + "usa": "usa", + // V-me / WE / Weather / WGN + "vme": "v-me", + "vmekids": "vme-kids", + "we": "we-tv", + "weath": "weather-channel", + "weather": "weather-channel", + "wgn": "wgn-america", + // Bravo / Syfy + "bravo": "bravo", + "syfy": "syfy", + // HBO + "hbo": "hbo", + "hbo2": "hbo-2", + "hbocom": "hbo-comedy", + "hbofam": "hbo-family", + "hboltn": "hbo-latino", + "hbosig": "hbo-signature", + "hbozn": "hbo-zone", + // Gol TV / Misc + "goltv": "gol-tv", + "goltve": "gol-tv", + "gol": "gol-tv", + "getv": "get-tv", + "gettv": "get-tv", + "local": "local-now", + "logo": "logo", + "hdnetmv": "hdnet-movies", +} + // helps split compound callsigns like "ESPNHD" → "espn". var knownPrefixes = []string{ "espn", "fox", "hbo", "cnn", "tbs", "tnt", "usa", "amc", @@ -171,40 +508,51 @@ func (c *Client) generateCandidates(callSign, affiliateName, channelNo string) [ affiliate := strings.ToLower(strings.TrimSpace(affiliateName)) call := strings.ToLower(strings.TrimSpace(callSign)) + bare := bareCallSign(call) - // 1. Check alias table first (handles long-form and irregular names) + // 1. Check affiliate alias table (handles long-form and irregular names) if alias, ok := affiliateAliases[affiliate]; ok { add(alias) } - // 2. Full affiliate slug — no stripping; matches "action-channel", "history-channel", etc. + // 2. Callsign abbreviation map — covers the common case where the provider + // sends a cryptic callsign with no affiliate name (e.g., "HISTORY" → "history-channel"). + // Try raw callsign first, then suffix-stripped form. + if slug, ok := callsignSlugs[call]; ok { + add(slug) + } + if bare != call { + if slug, ok := callsignSlugs[bare]; ok { + add(slug) + } + } + + // 3. Full affiliate slug — no stripping; matches "action-channel", "history-channel", etc. add(slugify(affiliate)) - // 3. {network}-{channelNo}-{callsign} — matches local affiliate logos like "abc-7-kabc" - bare := bareCallSign(call) + // 4. {network}-{channelNo}-{callsign} — matches local affiliate logos like "abc-7-kabc" if network, ok := networkSlugs[affiliate]; ok && bare != "" { if channelNo != "" { add(network + "-" + channelNo + "-" + bare) } - // 4. {network}-{callsign} — matches "abc-kota", "nbc-kdlt", "fox-wjzy", etc. + // 5. {network}-{callsign} — matches "abc-kota", "nbc-kdlt", "fox-wjzy", etc. add(network + "-" + bare) } - // 5. Bare callsign alone — matches standalone entries like "wjxt", "wlny" + // 6. Bare callsign alone — matches standalone entries like "wjxt", "wlny" add(bare) - // 6. Affiliate without leading "the" — "The Weather Channel" → "weather-channel" + // 7. Affiliate without leading "the" — "The Weather Channel" → "weather-channel" if strings.HasPrefix(affiliate, "the ") { add(slugify(strings.TrimPrefix(affiliate, "the "))) } - // 7. Normalized affiliate (strip noise words) — fallback for unusual long-form names + // 8. Normalized affiliate (strip noise words) — fallback for unusual long-form names add(normalizeAffiliate(affiliate)) - // 8. Known-prefix extraction for compound callsigns like "ESPNHD" → "espn" - stripped := hdSuffixRe.ReplaceAllString(call, "") + // 9. Known-prefix extraction for compound callsigns like "ESPNHD" → "espn" for _, prefix := range knownPrefixes { - if strings.HasPrefix(stripped, prefix) && len(stripped) > len(prefix) { + if strings.HasPrefix(bare, prefix) && len(bare) > len(prefix) { add(prefix) break } @@ -225,10 +573,18 @@ func normalizeAffiliate(name string) string { return slugify(strings.Join(kept, " ")) } -// returns the bare callsign with dash-separated and inline HD/SD/DT suffixes removed. +// returns the bare callsign with dash-separated and inline HD/SD/DT/Plus suffixes +// removed. Strips iteratively so compound suffixes like "HDP" or "HD2" collapse all the +// way (e.g., MAXHDP → MAXHD → MAX). Stops if the result would be shorter than 2 chars. func bareCallSign(call string) string { s := dashSuffixRe.ReplaceAllString(call, "") - s = hdSuffixRe.ReplaceAllString(s, "") + for { + next := hdSuffixRe.ReplaceAllString(s, "") + if next == s || len(next) < 2 { + break + } + s = next + } return strings.Trim(s, "- ") } From 23c373bf47b92f1315a6e79debc6dbac25612f32 Mon Sep 17 00:00:00 2001 From: form400 <155935848+form400@users.noreply.github.com> Date: Sun, 17 May 2026 15:07:44 -0500 Subject: [PATCH 4/5] gitignore local analysis artifacts Co-Authored-By: Claude Opus 4.7 --- .gitignore | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 847e211..f3320e9 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,10 @@ tmdb_cache.json tvlogo_cache.json guide_cache.json image_cache/ -demo.gif \ No newline at end of file +demo.gif + +# Local analysis artifacts (not part of the project) +*.ps1 +failing_channels.csv +missing_channels.txt +.claude/ \ No newline at end of file From 264a3fc9dc319e4af4af584eb14f463282633890 Mon Sep 17 00:00:00 2001 From: form400 <155935848+form400@users.noreply.github.com> Date: Sun, 17 May 2026 15:08:18 -0500 Subject: [PATCH 5/5] gitignore DEPLOY.md (local-only deployment notes) Co-Authored-By: Claude Opus 4.7 --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index f3320e9..645ee84 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,5 @@ demo.gif *.ps1 failing_channels.csv missing_channels.txt -.claude/ \ No newline at end of file +.claude/ +DEPLOY.md \ No newline at end of file