1// Package hdhr speaks SiliconDust's HDHomeRun protocols both ways.2//3// Server makes Airwaves look like an HDHomeRun network tuner, so apps such4// as Channels DVR, Jellyfin, Emby, VLC and the HDHomeRun app can use its5// channels, including custom ones that have no tuner behind them. It6// serves the HDHomeRun HTTP API (discover.json, lineup.json, /auto/v7// streams) plus an M3U playlist and XMLTV guide, and answers the UDP8// discovery broadcast on port 65001.9//10// Device is a real HDHomeRun, found by the same discovery broadcast and11// reached over its HTTP API: its lineup and channel scan, its tuners'12// status and signal, and streams of whole RF channels.13package hdhr1415import (16 "context"17 "encoding/json"18 "fmt"19 "io"20 "log"21 "net"22 "net/http"23 "strings"24)2526// Entry is one channel in the lineup.27type Entry struct {28 Number string // "7.1"29 Name string // "KMGH" or "Airwaves Weather"30 HD bool31 // Logo is an absolute URL, or a path on this server32 // ("/channel-logos/1.4"); optional.33 Logo string34 Group string // M3U group, e.g. "Antenna" or "Airwaves"35}3637// Backend supplies the lineup, streams and guide.38type Backend interface {39 Lineup(ctx context.Context) ([]Entry, error)40 // Stream writes the channel's MPEG-TS to w until ctx ends or the41 // source stops.42 Stream(ctx context.Context, number string, w io.Writer) error43 // XMLTV writes the guide for the lineup. abs makes a path on this44 // server an absolute URL, as the client reached it.45 XMLTV(ctx context.Context, w io.Writer, abs func(path string) string) error46}4748// Server is the emulated device.49type Server struct {50 Backend Backend51 DeviceID uint3252 FriendlyName string53 Tuners func() int54 // Logos serves /channel-logos/ and /guide-art/, the custom channels'55 // logos and the programs' pictures the lineup's and guide's paths56 // point at; optional.57 Logos http.Handler58 // FromHost takes the address clients reach the device at from their59 // requests' Host header, not the connection's local address. Set it60 // when the device isn't discovered on the LAN: clients then use an61 // address someone gave them, while the local address may be a62 // container's own, behind a port mapping.63 FromHost bool64}6566// baseURL is the address the client used to reach us, so replies work for67// LAN and tailnet clients alike.68func (s *Server) baseURL(r *http.Request) string {69 if s.FromHost && r.Host != "" {70 return "http://" + r.Host71 }72 if addr, ok := r.Context().Value(http.LocalAddrContextKey).(net.Addr); ok {73 return "http://" + addr.String()74 }75 return "http://" + r.Host76}7778// Handler serves the HDHomeRun API.79func (s *Server) Handler() http.Handler {80 mux := http.NewServeMux()81 mux.HandleFunc("GET /discover.json", func(w http.ResponseWriter, r *http.Request) {82 base := s.baseURL(r)83 writeJSON(w, map[string]any{84 "FriendlyName": s.FriendlyName,85 "Manufacturer": "Silicondust",86 "ModelNumber": "HDTC-2US",87 "FirmwareName": "hdhomeruntc_atsc",88 "FirmwareVersion": "20250101",89 "DeviceID": fmt.Sprintf("%08X", s.DeviceID),90 "DeviceAuth": "airwaves",91 "TunerCount": s.Tuners(),92 "BaseURL": base,93 "LineupURL": base + "/lineup.json",94 })95 })96 mux.HandleFunc("GET /lineup_status.json", func(w http.ResponseWriter, r *http.Request) {97 writeJSON(w, map[string]any{"ScanInProgress": 0, "ScanPossible": 1, "Source": "Antenna", "SourceList": []string{"Antenna"}})98 })99 // Apps ask the tuner to rescan. A scan takes every real tuner, so100 // that's left to Reception's Scan for channels: accept and do nothing.101 mux.HandleFunc("POST /lineup.post", func(w http.ResponseWriter, r *http.Request) {})102 mux.HandleFunc("GET /lineup.json", func(w http.ResponseWriter, r *http.Request) {103 lineup, err := s.Backend.Lineup(r.Context())104 if err != nil {105 http.Error(w, err.Error(), http.StatusServiceUnavailable)106 return107 }108 base := s.baseURL(r)109 out := make([]map[string]any, 0, len(lineup))110 for _, e := range lineup {111 m := map[string]any{"GuideNumber": e.Number, "GuideName": e.Name, "URL": base + "/auto/v" + e.Number}112 if e.HD {113 m["HD"] = 1114 }115 out = append(out, m)116 }117 writeJSON(w, out)118 })119 mux.HandleFunc("GET /channels.m3u", func(w http.ResponseWriter, r *http.Request) {120 lineup, err := s.Backend.Lineup(r.Context())121 if err != nil {122 http.Error(w, err.Error(), http.StatusServiceUnavailable)123 return124 }125 base := s.baseURL(r)126 w.Header().Set("Content-Type", "audio/x-mpegurl")127 fmt.Fprintf(w, "#EXTM3U url-tvg=\"%s/xmltv.xml\"\n", base)128 for _, e := range lineup {129 logo := e.Logo130 if strings.HasPrefix(logo, "/") {131 logo = base + logo132 }133 fmt.Fprintf(w, "#EXTINF:-1 tvg-id=%q tvg-chno=%q tvg-name=%q tvg-logo=%q group-title=%q,%s\n%s/auto/v%s\n",134 ChannelID(e.Number), e.Number, e.Name, logo, e.Group, e.Name, base, e.Number)135 }136 })137 mux.HandleFunc("GET /xmltv.xml", func(w http.ResponseWriter, r *http.Request) {138 w.Header().Set("Content-Type", "application/xml")139 base := s.baseURL(r)140 if err := s.Backend.XMLTV(r.Context(), w, func(path string) string { return base + path }); err != nil {141 log.Printf("xmltv: %v", err)142 }143 })144 if s.Logos != nil {145 mux.Handle("GET /channel-logos/{number}", s.Logos)146 mux.Handle("GET /guide-art/{name}", s.Logos)147 }148 stream := func(w http.ResponseWriter, r *http.Request) {149 number := strings.TrimPrefix(r.PathValue("ch"), "v")150 w.Header().Set("Content-Type", "video/mp2t")151 if err := s.Backend.Stream(r.Context(), number, w); err != nil && r.Context().Err() == nil {152 log.Printf("hdhr stream %s: %v", number, err)153 }154 }155 mux.HandleFunc("GET /auto/{ch}", stream)156 // Some apps address a specific tuner: /tuner0/v7.1.157 mux.HandleFunc("GET /{tuner}/{ch}", func(w http.ResponseWriter, r *http.Request) {158 if !strings.HasPrefix(r.PathValue("tuner"), "tuner") {159 http.NotFound(w, r)160 return161 }162 stream(w, r)163 })164 return mux165}166167// ChannelID is the XMLTV channel id for a virtual channel number.168func ChannelID(number string) string { return "airwaves." + number }169170func writeJSON(w http.ResponseWriter, v any) {171 w.Header().Set("Content-Type", "application/json")172 _ = json.NewEncoder(w).Encode(v)173}