From 36bccafc8ee3ec505b5e3d656c04ee2e6497bf27 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Sat, 11 Aug 2018 16:56:59 -0700 Subject: [PATCH 001/114] Extend m3u.Track to return line number and raw line --- m3u/main.go | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/m3u/main.go b/m3u/main.go index ee33ce7..5aa449f 100644 --- a/m3u/main.go +++ b/m3u/main.go @@ -18,10 +18,12 @@ type Playlist struct { // Track represents an m3u track type Track struct { - Name string - Length float64 - URI string - Tags map[string]string + Name string + Length float64 + URI string + Tags map[string]string + Raw string + LineNumber int } // UnmarshalTags will decode the Tags map into a struct containing fields with `m3u` tags matching map keys. @@ -72,19 +74,22 @@ func decode(playlist *Playlist, buf *bytes.Buffer) error { return fmt.Errorf("malformed M3U provided") } - if err = decodeLine(playlist, line); err != nil { + if err = decodeLine(playlist, line, lineNum); err != nil { return err } } return nil } -func decodeLine(playlist *Playlist, line string) error { +func decodeLine(playlist *Playlist, line string, lineNumber int) error { line = strings.TrimSpace(line) switch { case strings.HasPrefix(line, "#EXTINF:"): - track := new(Track) + track := &Track{ + Raw: line, + LineNumber: lineNumber, + } track.Length, track.Name, track.Tags = decodeInfoLine(line) From a1948f2d496ac7ef6a4be3d976eb526cf66ed7f0 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Sat, 11 Aug 2018 23:28:31 -0700 Subject: [PATCH 002/114] Checkpoint on internal overhaul of playlist/tracks/lineup/channels. --- lineup.go | 247 +++++++++++++++++++++++++++++++++++++++++++++++++++++ main.go | 132 +++++++--------------------- routes.go | 24 ++++-- structs.go | 53 ++++++------ 4 files changed, 323 insertions(+), 133 deletions(-) create mode 100644 lineup.go diff --git a/lineup.go b/lineup.go new file mode 100644 index 0000000..9d6747a --- /dev/null +++ b/lineup.go @@ -0,0 +1,247 @@ +package main + +import ( + "encoding/base64" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" + + "github.com/tombowditch/telly/m3u" +) + +// Track describes a single M3U segment. This struct includes m3u.Track as well as specific IPTV fields we want to get. +type Track struct { + *m3u.Track + SafeURI string `json:"URI"` + Catchup string `m3u:"catchup" json:",omitempty"` + CatchupDays string `m3u:"catchup-days" json:",omitempty"` + CatchupSource string `m3u:"catchup-source" json:",omitempty"` + GroupTitle string `m3u:"group-title" json:",omitempty"` + TvgID string `m3u:"tvg-id" json:",omitempty"` + TvgLogo string `m3u:"tvg-logo" json:",omitempty"` + TvgName string `m3u:"tvg-name" json:",omitempty"` +} + +// Channel returns a Channel struct for the given Track. +func (t *Track) Channel(number int, obfuscate bool) *HDHomeRunChannel { + var finalName string + if t.TvgName == "" { + finalName = t.Name + } else { + finalName = t.TvgName + } + + // base64 url + fullTrackURI := t.URI + if obfuscate { + trackURI := base64.StdEncoding.EncodeToString([]byte(t.URI)) + fullTrackURI = fmt.Sprintf("http://%s/stream/%s", opts.BaseAddress.String(), trackURI) + } + + // if strings.Contains(t.URI, ".m3u8") { + // log.Warnln("your .m3u contains .m3u8's. Plex has either stopped supporting m3u8 or it is a bug in a recent version - please use .ts! telly will automatically convert these in a future version. See telly github issue #108") + // } + + hd := false + if strings.Contains(strings.ToLower(t.Track.Raw), "hd") { + hd = true + } + + return &HDHomeRunChannel{ + GuideNumber: number, + GuideName: finalName, + URL: fullTrackURI, + HD: convertibleBoolean(hd), + + track: t, + } +} + +// Playlist describes a single M3U playlist. +type Playlist struct { + *m3u.Playlist + *M3UFile + + Tracks []Track + Channels []HDHomeRunChannel + TracksCount int + FilteredTracksCount int +} + +// Filter will filter the raw m3u.Playlist m3u.Track slice into the Track slice of the Playlist. +func (p *Playlist) Filter() error { + for _, oldTrack := range p.Playlist.Tracks { + track := Track{ + Track: oldTrack, + SafeURI: safeStringsRegex.ReplaceAllStringFunc(oldTrack.URI, stringSafer), + } + if unmarshalErr := oldTrack.UnmarshalTags(&track); unmarshalErr != nil { + return unmarshalErr + } + + if opts.Regex.MatchString(track.Name) == opts.RegexInclusive { + p.Tracks = append(p.Tracks, track) + } + } + + return nil +} + +// M3UFile describes a path and transport to a M3U provided in the configuration. +type M3UFile struct { + Path string `json:"-"` + SafePath string `json:"Path"` + Transport string +} + +// HDHomeRunChannel is a single channel found in the playlist. +type HDHomeRunChannel struct { + // These fields match what HDHomeRun uses and Plex expects to see. + AudioCodec string `json:",omitempty"` + DRM convertibleBoolean `json:",string,omitempty"` + Favorite convertibleBoolean `json:",string,omitempty"` + GuideName string `json:",omitempty"` + GuideNumber int `json:",string,omitempty"` + HD convertibleBoolean `json:",string,omitempty"` + URL string `json:",omitempty"` + VideoCodec string `json:",omitempty"` + + track *Track +} + +// Lineup is a collection of tracks +type Lineup struct { + Playlists []Playlist + PlaylistsCount int + TracksCount int + FilteredTracksCount int + + StartingChannelNumber int + channelNumber int + ObfuscateURL bool + + Refreshing bool + LastRefreshed time.Time `json:",omitempty"` +} + +// NewLineup returns a new Lineup for the given config struct. +func NewLineup(opts config) *Lineup { + return &Lineup{ + StartingChannelNumber: opts.StartingChannel, + channelNumber: opts.StartingChannel, + ObfuscateURL: !opts.DirectMode, + Refreshing: true, + LastRefreshed: time.Now(), + } +} + +// AddPlaylist adds a new playlist to the Lineup. +func (l *Lineup) AddPlaylist(path string) error { + reader, info, readErr := l.getM3U(path) + if readErr != nil { + log.WithError(readErr).Errorln("error getting m3u") + return readErr + } + + rawPlaylist, err := m3u.Decode(reader) + if err != nil { + log.WithError(err).Errorln("unable to parse m3u file") + return err + } + + playlist, playlistErr := l.NewPlaylist(rawPlaylist, info) + if playlistErr != nil { + return playlistErr + } + + l.Playlists = append(l.Playlists, *playlist) + l.TracksCount = l.TracksCount + playlist.TracksCount + l.FilteredTracksCount = l.FilteredTracksCount + playlist.FilteredTracksCount + + return nil +} + +// NewPlaylist will return a new and filtered Playlist for the given m3u.Playlist and M3UFile. +func (l *Lineup) NewPlaylist(rawPlaylist *m3u.Playlist, info *M3UFile) (*Playlist, error) { + playlist := &Playlist{rawPlaylist, info, nil, nil, len(rawPlaylist.Tracks), 0} + + if filterErr := playlist.Filter(); filterErr != nil { + log.WithError(filterErr).Errorln("error during filtering of channels, check your regex and try again") + return nil, filterErr + } + + for _, track := range playlist.Tracks { + + channel := track.Channel(l.channelNumber, l.ObfuscateURL) + + playlist.Channels = append(playlist.Channels, *channel) + + l.channelNumber = l.channelNumber + 1 + } + + playlist.FilteredTracksCount = len(playlist.Tracks) + exposedChannels.Add(float64(playlist.FilteredTracksCount)) + log.Debugf("Added %d channels to the lineup", playlist.FilteredTracksCount) + + return playlist, nil +} + +// Refresh will rescan all playlists for any channel changes. +func (l *Lineup) Refresh() error { + + log.Warnln("Refreshing the lineup!") + + l.Refreshing = true + + existingPlaylists := make([]Playlist, len(l.Playlists)) + copy(existingPlaylists, l.Playlists) + + l.Playlists = nil + l.TracksCount = 0 + l.FilteredTracksCount = 0 + l.StartingChannelNumber = 0 + + for _, playlist := range existingPlaylists { + if addErr := l.AddPlaylist(playlist.M3UFile.Path); addErr != nil { + return addErr + } + } + + l.LastRefreshed = time.Now() + l.Refreshing = false + + return nil +} + +func (l *Lineup) getM3U(path string) (io.Reader, *M3UFile, error) { + safePath := safeStringsRegex.ReplaceAllStringFunc(path, stringSafer) + log.Infof("Loading M3U from %s", safePath) + + info := &M3UFile{ + Path: path, + SafePath: safePath, + Transport: "disk", + } + + if strings.HasPrefix(strings.ToLower(path), "http") { + resp, err := http.Get(path) + if err != nil { + return nil, nil, err + } + //defer resp.Body.Close() + + info.Transport = "http" + + return resp.Body, info, nil + } + + file, fileErr := os.Open(path) + if fileErr != nil { + return nil, nil, fileErr + } + + return file, info, nil +} diff --git a/main.go b/main.go index b31a288..8b0ba1a 100644 --- a/main.go +++ b/main.go @@ -1,18 +1,13 @@ package main import ( - "encoding/base64" "fmt" - "io" - "net/http" - "os" - "strconv" + "regexp" "strings" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/common/version" "github.com/sirupsen/logrus" - "github.com/tombowditch/telly/m3u" kingpin "gopkg.in/alecthomas/kingpin.v2" ) @@ -27,6 +22,23 @@ var ( Help: "Number of exposed channels.", }, ) + + safeStringsRegex = regexp.MustCompile(`(?m)(username|password|token)=[\w=]+(&?)`) + + stringSafer = func(input string) string { + ret := input + if strings.HasPrefix(input, "username=") { + ret = "username=hunter1" + } else if strings.HasPrefix(input, "password=") { + ret = "password=hunter2" + } else if strings.HasPrefix(input, "token=") { + ret = "token=bm90Zm9yeW91" // "notforyou" + } + if strings.HasSuffix(input, "&") { + return fmt.Sprintf("%s&", ret) + } + return ret + } ) func main() { @@ -54,7 +66,7 @@ func main() { kingpin.Flag("log.requests", "Log HTTP requests $(TELLY_LOG_REQUESTS)").Envar("TELLY_LOG_REQUESTS").Default("false").BoolVar(&opts.LogRequests) // IPTV flags - kingpin.Flag("iptv.playlist", "Location of playlist M3U file. Can be on disk or a URL. $(TELLY_IPTV_PLAYLIST)").Envar("TELLY_IPTV_PLAYLIST").Default("iptv.m3u").StringVar(&opts.M3UPath) + kingpin.Flag("iptv.playlist", "Location of playlist M3U file. Can be on disk or a URL. $(TELLY_IPTV_PLAYLIST)").Envar("TELLY_IPTV_PLAYLIST").Default("iptv.m3u").StringsVar(&opts.Playlists) kingpin.Flag("iptv.streams", "Number of concurrent streams allowed $(TELLY_IPTV_STREAMS)").Envar("TELLY_IPTV_STREAMS").Default("1").IntVar(&opts.ConcurrentStreams) kingpin.Flag("iptv.direct", "If true, stream URLs will not be obfuscated to hide them from Plex. $(TELLY_IPTV_DIRECT)").Envar("TELLY_IPTV_DIRECT").Default("false").BoolVar(&opts.DirectMode) kingpin.Flag("iptv.starting-channel", "The channel number to start exposing from. $(TELLY_IPTV_STARTING_CHANNEL)").Envar("TELLY_IPTV_STARTING_CHANNEL").Default("10000").IntVar(&opts.StartingChannel) @@ -74,6 +86,7 @@ func main() { } log.SetLevel(level) + opts.FriendlyName = fmt.Sprintf("HDHomerun (%s)", opts.FriendlyName) opts.DeviceUUID = fmt.Sprintf("%d-AE2A-4E54-BBC9-33AF7D5D6A92", opts.DeviceID) if opts.BaseAddress.IP.IsUnspecified() { @@ -84,110 +97,23 @@ func main() { log.Warnln("You are listening on all interfaces but your base URL is localhost (meaning Plex will try and load localhost to access your streams) - is this intended?") } - if opts.M3UPath == "iptv.m3u" { + if len(opts.Playlists) == 1 && opts.Playlists[0] == "iptv.m3u" { log.Warnln("using default m3u option, 'iptv.m3u'. launch telly with the --iptv.playlist=yourfile.m3u option to change this!") } - m3uReader, readErr := getM3U(opts) - if readErr != nil { - log.WithError(readErr).Panicln("error getting m3u") - } - - playlist, err := m3u.Decode(m3uReader) - if err != nil { - log.WithError(err).Panicln("unable to parse m3u file") - } - - channels, filterErr := filterTracks(playlist.Tracks) - if filterErr != nil { - log.WithError(filterErr).Panicln("error during filtering of channels, check your regex and try again") - } - - log.Debugln("Building lineup") - - opts.lineup = buildLineup(opts, channels) - - channelCount := len(channels) - exposedChannels.Set(float64(channelCount)) - log.Infof("found %d channels", channelCount) - - if channelCount > 420 { - log.Warnln("telly has loaded more than 420 channels. Plex does not deal well with more than this amount and will more than likely hang when trying to fetch channels. You have been warned!") - } - - opts.FriendlyName = fmt.Sprintf("HDHomerun (%s)", opts.FriendlyName) - - serve(opts) -} - -func buildLineup(opts config, channels []Track) []LineupItem { - lineup := make([]LineupItem, 0) - gn := opts.StartingChannel - - for _, track := range channels { - - var finalName string - if track.TvgName == "" { - finalName = track.Name - } else { - finalName = track.TvgName - } - - // base64 url - fullTrackURI := track.URI - if !opts.DirectMode { - trackURI := base64.StdEncoding.EncodeToString([]byte(track.URI)) - fullTrackURI = fmt.Sprintf("http://%s/stream/%s", opts.BaseAddress.String(), trackURI) - } - - if strings.Contains(track.URI, ".m3u8") { - log.Warnln("your .m3u contains .m3u8's. Plex has either stopped supporting m3u8 or it is a bug in a recent version - please use .ts! telly will automatically convert these in a future version. See telly github issue #108") - } - - lu := LineupItem{ - GuideNumber: strconv.Itoa(gn), - GuideName: finalName, - URL: fullTrackURI, - } - - lineup = append(lineup, lu) - - gn = gn + 1 - } - - return lineup -} + opts.lineup = NewLineup(opts) -func getM3U(opts config) (io.Reader, error) { - if strings.HasPrefix(strings.ToLower(opts.M3UPath), "http") { - log.Debugf("Downloading M3U from %s", opts.M3UPath) - resp, err := http.Get(opts.M3UPath) - if err != nil { - return nil, err + for _, playlistPath := range opts.Playlists { + if addErr := opts.lineup.AddPlaylist(playlistPath); addErr != nil { + log.WithError(addErr).Panicln("error adding new playlist to lineup") } - //defer resp.Body.Close() - - return resp.Body, nil } - log.Debugf("Reading M3U file %s...", opts.M3UPath) - - return os.Open(opts.M3UPath) -} + log.Infof("Loaded %d channels into the lineup", opts.lineup.FilteredTracksCount) -func filterTracks(tracks []*m3u.Track) ([]Track, error) { - allowedTracks := make([]Track, 0) - - for _, oldTrack := range tracks { - track := Track{Track: oldTrack} - if unmarshalErr := oldTrack.UnmarshalTags(&track); unmarshalErr != nil { - return nil, unmarshalErr - } - - if opts.Regex.MatchString(track.Name) == opts.RegexInclusive { - allowedTracks = append(allowedTracks, track) - } + if opts.lineup.FilteredTracksCount > 420 { + log.Warnln("telly has loaded more than 420 channels into the lineup. Plex does not deal well with more than this amount and will more than likely hang when trying to fetch channels. You have been warned!") } - return allowedTracks, nil + serve(opts) } diff --git a/routes.go b/routes.go index f1d1139..c48c89e 100644 --- a/routes.go +++ b/routes.go @@ -35,17 +35,31 @@ func serve(opts config) { router.GET("/", deviceXML(upnp)) router.GET("/discover.json", discovery(discoveryData)) router.GET("/lineup_status.json", lineupStatus(LineupStatus{ - ScanInProgress: 0, - ScanPossible: 1, + ScanInProgress: convertibleBoolean(opts.lineup.Refreshing), + ScanPossible: convertibleBoolean(true), Source: "Cable", SourceList: []string{"Cable"}, })) - router.GET("/lineup.post", func(c *gin.Context) { - c.AbortWithStatus(http.StatusNotImplemented) + router.POST("/lineup.post", func(c *gin.Context) { + scanAction := c.Query("scan") + if scanAction == "start" { + if refreshErr := opts.lineup.Refresh(); refreshErr != nil { + c.AbortWithError(http.StatusInternalServerError, refreshErr) + } + c.AbortWithStatus(http.StatusOK) + return + } else if scanAction == "abort" { + c.AbortWithStatus(http.StatusOK) + return + } + c.String(http.StatusBadRequest, "%s is not a valid scan command", scanAction) }) router.GET("/device.xml", deviceXML(upnp)) router.GET("/lineup.json", lineup(opts.lineup)) router.GET("/stream/:channelID", stream) + router.GET("/debug.json", func(c *gin.Context) { + c.JSON(http.StatusOK, opts.lineup) + }) if opts.SSDP { log.Debugln("advertising telly service on network via UPNP/SSDP") @@ -78,7 +92,7 @@ func lineupStatus(status LineupStatus) gin.HandlerFunc { } } -func lineup(lineup []LineupItem) gin.HandlerFunc { +func lineup(lineup *Lineup) gin.HandlerFunc { return func(c *gin.Context) { c.JSON(http.StatusOK, lineup) } diff --git a/structs.go b/structs.go index 6f1c25c..c2d6110 100644 --- a/structs.go +++ b/structs.go @@ -1,13 +1,12 @@ package main import ( + "encoding/json" "encoding/xml" "fmt" "net" "regexp" "strconv" - - "github.com/tombowditch/telly/m3u" ) type config struct { @@ -15,7 +14,7 @@ type config struct { Regex *regexp.Regexp DirectMode bool - M3UPath string + Playlists []string ConcurrentStreams int StartingChannel int @@ -35,7 +34,7 @@ type config struct { ListenAddress *net.TCPAddr BaseAddress *net.TCPAddr - lineup []LineupItem + lineup *Lineup } func (c *config) DiscoveryData() DiscoveryData { @@ -87,31 +86,12 @@ func (d *DiscoveryData) UPNP() UPNP { // LineupStatus exposes the status of the channel lineup. type LineupStatus struct { - ScanInProgress int - ScanPossible int + ScanInProgress convertibleBoolean + ScanPossible convertibleBoolean Source string SourceList []string } -// LineupItem is a single channel found in the playlist. -type LineupItem struct { - GuideNumber string - GuideName string - URL string -} - -// Track describes a single M3U segment. This struct includes m3u.Track as well as specific IPTV fields we want to get. -type Track struct { - *m3u.Track - Catchup string `m3u:"catchup"` - CatchupDays string `m3u:"catchup-days"` - CatchupSource string `m3u:"catchup-source"` - GroupTitle string `m3u:"group-title"` - TvgID string `m3u:"tvg-id"` - TvgLogo string `m3u:"tvg-logo"` - TvgName string `m3u:"tvg-name"` -} - type upnpVersion struct { Major int32 `xml:"major"` Minor int32 `xml:"minor"` @@ -134,3 +114,26 @@ type UPNP struct { URLBase string `xml:"URLBase"` Device upnpDevice `xml:"device"` } + +type convertibleBoolean bool + +func (bit *convertibleBoolean) MarshalJSON() ([]byte, error) { + var bitSetVar int8 + if *bit { + bitSetVar = 1 + } + + return json.Marshal(bitSetVar) +} + +func (bit *convertibleBoolean) UnmarshalJSON(data []byte) error { + asString := string(data) + if asString == "1" || asString == "true" { + *bit = true + } else if asString == "0" || asString == "false" { + *bit = false + } else { + return fmt.Errorf("Boolean unmarshal error: invalid input %s", asString) + } + return nil +} From acbdd527525b45099bc69462842ab5b1e9f3c807 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Sun, 12 Aug 2018 02:26:23 -0700 Subject: [PATCH 003/114] FIll PlaylistsCount --- lineup.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lineup.go b/lineup.go index 9d6747a..46427e7 100644 --- a/lineup.go +++ b/lineup.go @@ -78,6 +78,7 @@ func (p *Playlist) Filter() error { Track: oldTrack, SafeURI: safeStringsRegex.ReplaceAllStringFunc(oldTrack.URI, stringSafer), } + if unmarshalErr := oldTrack.UnmarshalTags(&track); unmarshalErr != nil { return unmarshalErr } @@ -158,6 +159,7 @@ func (l *Lineup) AddPlaylist(path string) error { } l.Playlists = append(l.Playlists, *playlist) + l.PlaylistsCount = len(l.Playlists) l.TracksCount = l.TracksCount + playlist.TracksCount l.FilteredTracksCount = l.FilteredTracksCount + playlist.FilteredTracksCount From 4cdf2a9b058a22b4119439a9c44811f3b1a9ed9f Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Sun, 12 Aug 2018 17:14:24 -0700 Subject: [PATCH 004/114] XMLTV/EPG parsing checkpoint --- lineup.go | 199 +++++++++++++-- main.go | 13 +- routes.go | 15 +- xmltv/xmltv.dtd | 575 ++++++++++++++++++++++++++++++++++++++++++++ xmltv/xmltv.go | 204 ++++++++++++++++ xmltv/xmltv_test.go | 135 +++++++++++ 6 files changed, 1119 insertions(+), 22 deletions(-) create mode 100644 xmltv/xmltv.dtd create mode 100644 xmltv/xmltv.go create mode 100644 xmltv/xmltv_test.go diff --git a/lineup.go b/lineup.go index 46427e7..7727eba 100644 --- a/lineup.go +++ b/lineup.go @@ -2,14 +2,19 @@ package main import ( "encoding/base64" + "encoding/xml" "fmt" "io" "net/http" "os" + "regexp" + "sort" + "strconv" "strings" "time" "github.com/tombowditch/telly/m3u" + "github.com/tombowditch/telly/xmltv" ) // Track describes a single M3U segment. This struct includes m3u.Track as well as specific IPTV fields we want to get. @@ -23,6 +28,9 @@ type Track struct { TvgID string `m3u:"tvg-id" json:",omitempty"` TvgLogo string `m3u:"tvg-logo" json:",omitempty"` TvgName string `m3u:"tvg-name" json:",omitempty"` + + XMLTVChannel xmlTVChannel `json:",omitempty"` + XMLTVProgrammes []xmltv.Programme `json:",omitempty"` } // Channel returns a Channel struct for the given Track. @@ -98,9 +106,8 @@ type M3UFile struct { Transport string } -// HDHomeRunChannel is a single channel found in the playlist. +// HDHomeRunChannel is a HDHomeRun specification compatible representation of a Track available in the Lineup. type HDHomeRunChannel struct { - // These fields match what HDHomeRun uses and Plex expects to see. AudioCodec string `json:",omitempty"` DRM convertibleBoolean `json:",string,omitempty"` Favorite convertibleBoolean `json:",string,omitempty"` @@ -126,21 +133,40 @@ type Lineup struct { Refreshing bool LastRefreshed time.Time `json:",omitempty"` + + xmlTvChannelMap map[string]xmlTVChannel + channelsInXMLTv []string + xmlTv xmltv.TV + xmlTvSourceInfoURL []string + xmlTvSourceInfoName []string + xmlTvSourceDataURL []string } // NewLineup returns a new Lineup for the given config struct. func NewLineup(opts config) *Lineup { - return &Lineup{ + tv := xmltv.TV{ + GeneratorInfoName: namespaceWithVersion, + GeneratorInfoURL: "https://github.com/tombowditch/telly", + } + + lineup := &Lineup{ + xmlTv: tv, + xmlTvChannelMap: make(map[string]xmlTVChannel), StartingChannelNumber: opts.StartingChannel, channelNumber: opts.StartingChannel, ObfuscateURL: !opts.DirectMode, Refreshing: true, LastRefreshed: time.Now(), } + + return lineup } // AddPlaylist adds a new playlist to the Lineup. -func (l *Lineup) AddPlaylist(path string) error { +func (l *Lineup) AddPlaylist(plist string) error { + // Attempt to split the string by semi colon for complex config passing with m3uPath,xmlPath,name + splitStr := strings.Split(plist, ";") + path := splitStr[0] reader, info, readErr := l.getM3U(path) if readErr != nil { log.WithError(readErr).Errorln("error getting m3u") @@ -153,6 +179,23 @@ func (l *Lineup) AddPlaylist(path string) error { return err } + if len(splitStr) > 1 { + epg, epgReadErr := l.getXMLTV(splitStr[1]) + if epgReadErr != nil { + log.WithError(epgReadErr).Errorln("error getting XMLTV") + return epgReadErr + } + + chanMap, chanMapErr := l.processXMLTV(epg) + if chanMapErr != nil { + log.WithError(chanMapErr).Errorln("Error building channel mapping") + } + + for chanID, chann := range chanMap { + l.xmlTvChannelMap[chanID] = chann + } + } + playlist, playlistErr := l.NewPlaylist(rawPlaylist, info) if playlistErr != nil { return playlistErr @@ -175,13 +218,30 @@ func (l *Lineup) NewPlaylist(rawPlaylist *m3u.Playlist, info *M3UFile) (*Playlis return nil, filterErr } - for _, track := range playlist.Tracks { + for idx, track := range playlist.Tracks { + + channelNumber := l.channelNumber + + if xmlChan, ok := l.xmlTvChannelMap[track.TvgID]; ok && !contains(l.channelsInXMLTv, track.TvgID) { + log.Infoln("found an entry in xmlTvChannelMap for", track.TvgID) + channelNumber = xmlChan.Number + l.channelsInXMLTv = append(l.channelsInXMLTv, track.TvgID) + track.XMLTVChannel = xmlChan + l.xmlTv.Channels = append(l.xmlTv.Channels, xmlChan.Original) + if xmlChan.Programmes != nil { + track.XMLTVProgrammes = xmlChan.Programmes + l.xmlTv.Programmes = append(l.xmlTv.Programmes, xmlChan.Programmes...) + } + playlist.Tracks[idx] = track + } - channel := track.Channel(l.channelNumber, l.ObfuscateURL) + channel := track.Channel(channelNumber, l.ObfuscateURL) playlist.Channels = append(playlist.Channels, *channel) - l.channelNumber = l.channelNumber + 1 + if channelNumber == l.channelNumber { // Only increment lineup channel number if its for a channel that didnt have a XMLTV entry. + l.channelNumber = l.channelNumber + 1 + } } playlist.FilteredTracksCount = len(playlist.Tracks) @@ -222,28 +282,137 @@ func (l *Lineup) getM3U(path string) (io.Reader, *M3UFile, error) { safePath := safeStringsRegex.ReplaceAllStringFunc(path, stringSafer) log.Infof("Loading M3U from %s", safePath) - info := &M3UFile{ + file, transport, err := l.getFile(path) + if err != nil { + return nil, nil, err + } + + return file, &M3UFile{ Path: path, SafePath: safePath, - Transport: "disk", + Transport: transport, + }, nil +} + +func (l *Lineup) getXMLTV(path string) (*xmltv.TV, error) { + file, _, err := l.getFile(path) + if err != nil { + return nil, err } + decoder := xml.NewDecoder(file) + tvSetup := new(xmltv.TV) + if err := decoder.Decode(tvSetup); err != nil { + log.WithError(err).Errorln("Could not decode xmltv programme") + return nil, err + } + + return tvSetup, nil +} + +func (l *Lineup) getFile(path string) (io.Reader, string, error) { + safePath := safeStringsRegex.ReplaceAllStringFunc(path, stringSafer) + log.Infof("Loading file from %s", safePath) + + transport := "disk" + if strings.HasPrefix(strings.ToLower(path), "http") { resp, err := http.Get(path) if err != nil { - return nil, nil, err + return nil, transport, err } //defer resp.Body.Close() - info.Transport = "http" - - return resp.Body, info, nil + return resp.Body, transport, nil } file, fileErr := os.Open(path) if fileErr != nil { - return nil, nil, fileErr + return nil, transport, fileErr + } + + return file, transport, nil +} + +var channelNumberRegex = regexp.MustCompile(`^[0-9]+[[:space:]]?$`).MatchString +var callSignRegex = regexp.MustCompile(`^[A-Z0-9]+$`).MatchString + +type xmlTVChannel struct { + ID string + Number int + CallSign string + ShortName string + LongName string + + NumberAssigned bool + + Programmes []xmltv.Programme + + Original xmltv.Channel +} + +func (l *Lineup) processXMLTV(tv *xmltv.TV) (map[string]xmlTVChannel, error) { + programmeMap := make(map[string][]xmltv.Programme) + for _, programme := range tv.Programmes { + programmeMap[programme.Channel] = append(programmeMap[programme.Channel], programme) + } + + channelMap := make(map[string]xmlTVChannel, 0) + startManualNumber := 10000 + for _, tvChann := range tv.Channels { + xTVChan := xmlTVChannel{ + ID: tvChann.ID, + Original: tvChann, + } + if programmes, ok := programmeMap[tvChann.ID]; ok { + xTVChan.Programmes = programmes + } + displayNames := []string{} + for _, displayName := range tvChann.DisplayNames { + displayNames = append(displayNames, displayName.Value) + } + sort.StringSlice(displayNames).Sort() + for i := 0; i < 10; i++ { + iterateDisplayNames(displayNames, &xTVChan) + } + if xTVChan.Number == 0 { + xTVChan.Number = startManualNumber + 1 + startManualNumber = xTVChan.Number + xTVChan.NumberAssigned = true + } + channelMap[xTVChan.ID] = xTVChan } + return channelMap, nil +} - return file, info, nil +func iterateDisplayNames(displayNames []string, xTVChan *xmlTVChannel) { + for _, displayName := range displayNames { + if channelNumberRegex(displayName) { + if chanNum, chanNumErr := strconv.Atoi(displayName); chanNumErr == nil { + log.Debugln(displayName, "is channel number!") + xTVChan.Number = chanNum + } + } else if !strings.HasPrefix(displayName, fmt.Sprintf("%d", xTVChan.Number)) { + if xTVChan.LongName == "" { + xTVChan.LongName = displayName + log.Debugln(displayName, "is long name!") + } else if !callSignRegex(displayName) && len(xTVChan.LongName) < len(displayName) { + xTVChan.ShortName = xTVChan.LongName + xTVChan.LongName = displayName + log.Debugln(displayName, "is NEW long name, replacing", xTVChan.ShortName) + } else if callSignRegex(displayName) { + xTVChan.CallSign = displayName + log.Debugln(displayName, "is call sign!") + } + } + } +} + +func contains(s []string, e string) bool { + for _, a := range s { + if a == e { + return true + } + } + return false } diff --git a/main.go b/main.go index 8b0ba1a..7ae1f7d 100644 --- a/main.go +++ b/main.go @@ -12,9 +12,10 @@ import ( ) var ( - namespace = "telly" - log = logrus.New() - opts = config{} + namespace = "telly" + namespaceWithVersion = fmt.Sprintf("%s %s", namespace, version.Version) + log = logrus.New() + opts = config{} exposedChannels = prometheus.NewGauge( prometheus.GaugeOpts{ @@ -111,9 +112,9 @@ func main() { log.Infof("Loaded %d channels into the lineup", opts.lineup.FilteredTracksCount) - if opts.lineup.FilteredTracksCount > 420 { - log.Warnln("telly has loaded more than 420 channels into the lineup. Plex does not deal well with more than this amount and will more than likely hang when trying to fetch channels. You have been warned!") - } + // if opts.lineup.FilteredTracksCount > 420 { + // log.Panicln("telly has loaded more than 420 channels into the lineup. Plex does not deal well with more than this amount and will more than likely hang when trying to fetch channels. You must use regular expressions to filter out channels. You can also start another Telly instance.") + // } serve(opts) } diff --git a/routes.go b/routes.go index c48c89e..b952698 100644 --- a/routes.go +++ b/routes.go @@ -4,6 +4,7 @@ import ( "encoding/base64" "fmt" "net/http" + "sort" "time" "github.com/gin-gonic/gin" @@ -57,6 +58,7 @@ func serve(opts config) { router.GET("/device.xml", deviceXML(upnp)) router.GET("/lineup.json", lineup(opts.lineup)) router.GET("/stream/:channelID", stream) + router.GET("/epg.xml", xmlTV(opts.lineup)) router.GET("/debug.json", func(c *gin.Context) { c.JSON(http.StatusOK, opts.lineup) }) @@ -94,7 +96,18 @@ func lineupStatus(status LineupStatus) gin.HandlerFunc { func lineup(lineup *Lineup) gin.HandlerFunc { return func(c *gin.Context) { - c.JSON(http.StatusOK, lineup) + allChannels := make([]HDHomeRunChannel, 0) + for _, playlist := range lineup.Playlists { + allChannels = append(allChannels, playlist.Channels...) + } + sort.Slice(allChannels, func(i, j int) bool { return allChannels[i].GuideNumber < allChannels[j].GuideNumber }) + c.JSON(http.StatusOK, allChannels) + } +} + +func xmlTV(lineup *Lineup) gin.HandlerFunc { + return func(c *gin.Context) { + c.XML(http.StatusOK, lineup.xmlTv) } } diff --git a/xmltv/xmltv.dtd b/xmltv/xmltv.dtd new file mode 100644 index 0000000..3c4812e --- /dev/null +++ b/xmltv/xmltv.dtd @@ -0,0 +1,575 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/xmltv/xmltv.go b/xmltv/xmltv.go new file mode 100644 index 0000000..6795f43 --- /dev/null +++ b/xmltv/xmltv.go @@ -0,0 +1,204 @@ +// Package xmltv provides structures for parsing XMLTV data. +package xmltv + +import ( + "encoding/xml" + "os" + "time" + + "golang.org/x/net/html/charset" +) + +// Time that holds the time which is parsed from XML +type Time struct { + time.Time +} + +// MarshalXMLAttr is used to marshal a Go time.Time into the XMLTV Format. +func (t *Time) MarshalXMLAttr(name xml.Name) (xml.Attr, error) { + return xml.Attr{ + Name: name, + Value: t.Format("20060102150405 -0700"), + }, nil +} + +// UnmarshalXMLAttr is used to unmarshal a time in the XMLTV format to a time.Time. +func (t *Time) UnmarshalXMLAttr(attr xml.Attr) error { + t1, err := time.Parse("20060102150405 -0700", attr.Value) + if err != nil { + return err + } + + *t = Time{t1} + return nil +} + +// TV is the root element. +type TV struct { + Channels []Channel `xml:"channel" json:"channels"` + Programmes []Programme `xml:"programme" json:"programmes"` + Date string `xml:"date,attr,omitempty" json:"date,omitempty"` + SourceInfoURL string `xml:"source-info-url,attr,omitempty" json:"source_info_url,omitempty"` + SourceInfoName string `xml:"source-info-name,attr,omitempty" json:"source_info_name,omitempty"` + SourceDataURL string `xml:"source-data-url,attr,omitempty" json:"source_data_url,omitempty"` + GeneratorInfoName string `xml:"generator-info-name,attr,omitempty" json:"generator_info_name,omitempty"` + GeneratorInfoURL string `xml:"generator-info-url,attr,omitempty" json:"generator_info_url,omitempty"` +} + +// LoadXML loads the XMLTV XML from file. +func (t *TV) LoadXML(f *os.File) error { + decoder := xml.NewDecoder(f) + decoder.CharsetReader = charset.NewReaderLabel + + err := decoder.Decode(&t) + if err != nil { + return err + } + + return nil +} + +// Channel details of a channel +type Channel struct { + DisplayNames []CommonElement `xml:"display-name" json:"display_names" ` + Icons []Icon `xml:"icon,omitempty" json:"icons,omitempty"` + URLs []string `xml:"url,omitempty" json:"urls,omitempty" ` + ID string `xml:"id,attr" json:"id,omitempty" ` +} + +// Programme details of a single programme transmission +type Programme struct { + ID string `xml:"id,attr,omitempty" json:"id,omitempty"` // not defined by standard, but often present + Titles []CommonElement `xml:"title" json:"titles"` + SecondaryTitles []CommonElement `xml:"sub-title,omitempty" json:"secondary_titles,omitempty"` + Descriptions []CommonElement `xml:"desc,omitempty" json:"descriptions,omitempty"` + Credits *Credits `xml:"credits,omitempty" json:"credits,omitempty"` + Date string `xml:"date,omitempty" json:"date,omitempty"` + Categories []CommonElement `xml:"category,omitempty" json:"categories,omitempty"` + Keywords []CommonElement `xml:"keyword,omitempty" json:"keywords,omitempty"` + Languages []CommonElement `xml:"language,omitempty" json:"languages,omitempty"` + OrigLanguages []CommonElement `xml:"orig-language,omitempty" json:"orig_languages,omitempty"` + Length *Length `xml:"length,omitempty" json:"length,omitempty"` + Icons []Icon `xml:"icon,omitempty" json:"icons,omitempty"` + URLs []string `xml:"url,omitempty" json:"urls,omitempty"` + Countries []CommonElement `xml:"country,omitempty" json:"countries,omitempty"` + EpisodeNums []EpisodeNum `xml:"episode-num,omitempty" json:"episode_nums,omitempty"` + Video *Video `xml:"video,omitempty" json:"video,omitempty"` + Audio *Audio `xml:"audio,omitempty" json:"audio,omitempty"` + PreviouslyShown *PreviouslyShown `xml:"previously-shown,omitempty" json:"previously_shown,omitempty"` + Premiere *CommonElement `xml:"premiere,omitempty" json:"premiere,omitempty"` + LastChance *CommonElement `xml:"last-chance,omitempty" json:"last_chance,omitempty"` + New ElementPresent `xml:"new" json:"new"` + Subtitles []Subtitle `xml:"subtitles,omitempty" json:"subtitles,omitempty"` + Ratings []Rating `xml:"rating,omitempty" json:"ratings,omitempty"` + StarRatings []Rating `xml:"star-rating,omitempty" json:"star_ratings,omitempty"` + Reviews []Review `xml:"review,omitempty" json:"reviews,omitempty"` + Start *Time `xml:"start,attr" json:"start"` + Stop *Time `xml:"stop,attr,omitempty" json:"stop,omitempty"` + PDCStart *Time `xml:"pdc-start,attr,omitempty" json:"pdc_start,omitempty"` + VPSStart *Time `xml:"vps-start,attr,omitempty" json:"vps_start,omitempty"` + Showview string `xml:"showview,attr,omitempty" json:"showview,omitempty"` + Videoplus string `xml:"videoplus,attr,omitempty" json:"videoplus,omitempty"` + Channel string `xml:"channel,attr" json:"channel"` + Clumpidx string `xml:"clumpidx,attr,omitempty" json:"clumpidx,omitempty"` +} + +// CommonElement element structure that is common, i.e. Italy +type CommonElement struct { + Lang string `xml:"lang,attr,omitempty" json:"lang,omitempty" ` + Value string `xml:",chardata" json:"value,omitempty"` +} + +// ElementPresent used to determine if element is present or not +type ElementPresent bool + +// UnmarshalXML used to determine if the element is present or not. see https://stackoverflow.com/a/46516243 +func (c *ElementPresent) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { + var v string + if decodeErr := d.DecodeElement(&v, &start); decodeErr != nil { + return decodeErr + } + *c = true + return nil +} + +// Icon associated with the element that contains it +type Icon struct { + Source string `xml:"src,attr" json:"source"` + Width int `xml:"width,attr" json:"width"` + Height int `xml:"height,attr" json:"height"` +} + +// Credits for the programme +type Credits struct { + Directors []string `xml:"director,omitempty" json:"directors,omitempty"` + Actors []Actor `xml:"actor,omitempty" json:"actors,omitempty"` + Writers []string `xml:"writer,omitempty" json:"writers,omitempty"` + Adapters []string `xml:"adapter,omitempty" json:"adapters,omitempty"` + Producers []string `xml:"producer,omitempty" json:"producers,omitempty"` + Composers []string `xml:"composer,omitempty" json:"composers,omitempty"` + Editors []string `xml:"editor,omitempty" json:"editors,omitempty"` + Presenters []string `xml:"presenter,omitempty" json:"presenters,omitempty"` + Commentators []string `xml:"commentator,omitempty" json:"commentators,omitempty"` + Guests []string `xml:"guest,omitempty" json:"guests,omitempty"` +} + +// Actor in a programme +type Actor struct { + Role string `xml:"role,attr,omitempty" json:"role,omitempty"` + Value string `xml:",chardata" json:"value"` +} + +// Length of the programme +type Length struct { + Units string `xml:"units,attr" json:"units"` + Value string `xml:",chardata" json:"value"` +} + +// EpisodeNum of the programme +type EpisodeNum struct { + System string `xml:"system,attr,omitempty" json:"system,omitempty"` + Value string `xml:",chardata" json:"value"` +} + +// Video details of the programme +type Video struct { + Present string `xml:"present,omitempty" json:"present,omitempty"` + Colour string `xml:"colour,omitempty" json:"colour,omitempty"` + Aspect string `xml:"aspect,omitempty" json:"aspect,omitempty"` + Quality string `xml:"quality,omitempty" json:"quality,omitempty"` +} + +// Audio details of the programme +type Audio struct { + Present string `xml:"present,omitempty" json:"present,omitempty"` + Stereo string `xml:"stereo,omitempty" json:"stereo,omitempty"` +} + +// PreviouslyShown When and where the programme was last shown, if known. +type PreviouslyShown struct { + Start string `xml:"start,attr,omitempty" json:"start,omitempty"` + Channel string `xml:"channel,attr,omitempty" json:"channel,omitempty"` +} + +// Subtitle in a programme +type Subtitle struct { + Language *CommonElement `xml:"language,omitempty" json:"language,omitempty"` + Type string `xml:"type,attr,omitempty" json:"type,omitempty"` +} + +// Rating of a programme +type Rating struct { + Value string `xml:"value" json:"value"` + Icons []Icon `xml:"icon,omitempty" json:"icons,omitempty"` + System string `xml:"system,attr,omitempty" json:"system,omitempty"` +} + +// Review of a programme +type Review struct { + Value string `xml:",chardata" json:"value"` + Type string `xml:"type" json:"type"` + Source string `xml:"source,omitempty" json:"source,omitempty"` + Reviewer string `xml:"reviewer,omitempty" json:"reviewer,omitempty"` + Lang string `xml:"lang,omitempty" json:"lang,omitempty"` +} diff --git a/xmltv/xmltv_test.go b/xmltv/xmltv_test.go new file mode 100644 index 0000000..0cdc479 --- /dev/null +++ b/xmltv/xmltv_test.go @@ -0,0 +1,135 @@ +package xmltv + +import ( + "encoding/xml" + "fmt" + "io" + "os" + "reflect" + "testing" + "time" + + "github.com/kr/pretty" +) + +func dummyReader(charset string, input io.Reader) (io.Reader, error) { + return input, nil +} + +func TestDecode(t *testing.T) { + // Example downloaded from http://wiki.xmltv.org/index.php/XMLTVFormat + // One may check it with `xmllint --noout --dtdvalid xmltv.dtd example.xml` + f, err := os.Open("example.xml") + if err != nil { + t.Fatal(err) + } + defer f.Close() + + var tv TV + dec := xml.NewDecoder(f) + dec.CharsetReader = dummyReader + err = dec.Decode(&tv) + if err != nil { + t.Fatal(err) + } + + ch := Channel{ + ID: "I10436.labs.zap2it.com", + DisplayNames: []CommonElement{ + CommonElement{ + Value: "13 KERA", + }, + CommonElement{ + Value: "13 KERA TX42822:-", + }, + CommonElement{ + Value: "13", + }, + CommonElement{ + Value: "13 KERA fcc", + }, + CommonElement{ + Value: "KERA", + }, + CommonElement{ + Value: "KERA", + }, + CommonElement{ + Value: "PBS Affiliate", + }, + }, + Icons: []Icon{ + Icon{ + Source: `file://C:\Perl\site/share/xmltv/icons/KERA.gif`, + }, + }, + } + if !reflect.DeepEqual(ch, tv.Channels[0]) { + t.Errorf("\texpected: %# v\n\t\tactual: %# v\n", pretty.Formatter(ch), pretty.Formatter(tv.Channels[0])) + } + + loc := time.FixedZone("", -6*60*60) + pr := Programme{ + ID: "someId", + Date: "20080711", + Channel: "I10436.labs.zap2it.com", + Start: &Time{time.Date(2008, 07, 15, 0, 30, 0, 0, loc)}, + Stop: &Time{time.Date(2008, 07, 15, 1, 0, 0, 0, loc)}, + Titles: []CommonElement{ + CommonElement{ + Lang: "en", + Value: "NOW on PBS", + }, + }, + Descriptions: []CommonElement{ + CommonElement{ + Lang: "en", + Value: "Jordan's Queen Rania has made job creation a priority to help curb the staggering unemployment rates among youths in the Middle East.", + }, + }, + Categories: []CommonElement{ + CommonElement{ + Lang: "en", + Value: "Newsmagazine", + }, + CommonElement{ + Lang: "en", + Value: "Interview", + }, + CommonElement{ + Lang: "en", + Value: "Public affairs", + }, + CommonElement{ + Lang: "en", + Value: "Series", + }, + }, + EpisodeNums: []EpisodeNum{ + EpisodeNum{ + System: "dd_progid", + Value: "EP01006886.0028", + }, + EpisodeNum{ + System: "onscreen", + Value: "427", + }, + }, + Audio: &Audio{ + Stereo: "stereo", + }, + PreviouslyShown: &PreviouslyShown{ + Start: "20080711000000", + }, + Subtitles: []Subtitle{ + Subtitle{ + Type: "teletext", + }, + }, + } + if !reflect.DeepEqual(pr, tv.Programmes[0]) { + expected := fmt.Sprintf("\texpected: %# v\n\t\t\texpected start: %s\n\t\t\texpected stop : %s", pretty.Formatter(pr), pr.Start, pr.Stop) + actual := fmt.Sprintf("\tactual: %# v\n\t\t\tactual start: %s\n\t\t\tactual stop: %s", pretty.Formatter(tv.Programmes[0]), tv.Programmes[0].Start, tv.Programmes[0].Stop) + t.Errorf("%s\n%s\n", expected, actual) + } +} From d7f66faf4ff39ab05c78ba227630abe2d75c1aba Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Mon, 13 Aug 2018 19:55:01 -0700 Subject: [PATCH 005/114] XMLTV checkpoint --- Gopkg.lock | 49 ++++++++++- VERSION | 2 +- lineup.go | 217 +++++++++++++++++++++++++++++++------------------ main.go | 18 ++-- routes.go | 53 +++++++----- structs.go | 16 ++-- xmltv/xmltv.go | 18 +++- 7 files changed, 252 insertions(+), 121 deletions(-) diff --git a/Gopkg.lock b/Gopkg.lock index 6544274..cc87f39 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -64,6 +64,22 @@ pruneopts = "UT" revision = "4a0ed625a78b6858dc8d3a55fb7728968b712122" +[[projects]] + digest = "1:ca955a9cd5b50b0f43d2cc3aeb35c951473eeca41b34eb67507f1dbcc0542394" + name = "github.com/kr/pretty" + packages = ["."] + pruneopts = "UT" + revision = "73f6ac0b30a98e433b289500d779f50c1a6f0712" + version = "v0.1.0" + +[[projects]] + digest = "1:15b5cc79aad436d47019f814fde81a10221c740dc8ddf769221a65097fb6c2e9" + name = "github.com/kr/text" + packages = ["."] + pruneopts = "UT" + revision = "e2ffdb16a802fe2bb95e2e35ff34f0e53aeef34f" + version = "v0.1.0" + [[projects]] digest = "1:fa610f9fe6a93f4a75e64c83673dfff9bf1a34bbb21e6102021b6bc7850834a3" name = "github.com/mattn/go-isatty" @@ -165,10 +181,13 @@ [[projects]] branch = "master" - digest = "1:937d8f64b118c494c48b0cc9c990f2163c7483e6c70b5828f20006d81c61412f" + digest = "1:2d073118530c09a068ae1c47b054b5bdf75f625621658ecb642bcad7e65eb66a" name = "golang.org/x/net" packages = [ "bpf", + "html", + "html/atom", + "html/charset", "internal/iana", "internal/socket", "ipv4", @@ -187,6 +206,32 @@ pruneopts = "UT" revision = "98c5dad5d1a0e8a73845ecc8897d0bd56586511d" +[[projects]] + digest = "1:aa4d6967a3237f8367b6bf91503964a77183ecf696f1273e8ad3551bb4412b5f" + name = "golang.org/x/text" + packages = [ + "encoding", + "encoding/charmap", + "encoding/htmlindex", + "encoding/internal", + "encoding/internal/identifier", + "encoding/japanese", + "encoding/korean", + "encoding/simplifiedchinese", + "encoding/traditionalchinese", + "encoding/unicode", + "internal/gen", + "internal/tag", + "internal/utf8internal", + "language", + "runes", + "transform", + "unicode/cldr", + ] + pruneopts = "UT" + revision = "f21a4dfb5e38f5895301dc265a8def02365cc3d0" + version = "v0.3.0" + [[projects]] digest = "1:c06d9e11d955af78ac3bbb26bd02e01d2f61f689e1a3bce2ef6fb683ef8a7f2d" name = "gopkg.in/alecthomas/kingpin.v2" @@ -216,11 +261,13 @@ input-imports = [ "github.com/gin-gonic/gin", "github.com/koron/go-ssdp", + "github.com/kr/pretty", "github.com/mitchellh/mapstructure", "github.com/prometheus/client_golang/prometheus", "github.com/prometheus/common/version", "github.com/sirupsen/logrus", "github.com/zsais/go-gin-prometheus", + "golang.org/x/net/html/charset", "gopkg.in/alecthomas/kingpin.v2", ] solver-name = "gps-cdcl" diff --git a/VERSION b/VERSION index 21e8796..9084fa2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.3 +1.1.0 diff --git a/lineup.go b/lineup.go index 7727eba..bbfbb87 100644 --- a/lineup.go +++ b/lineup.go @@ -1,7 +1,6 @@ package main import ( - "encoding/base64" "encoding/xml" "fmt" "io" @@ -17,55 +16,38 @@ import ( "github.com/tombowditch/telly/xmltv" ) +var channelNumberRegex = regexp.MustCompile(`^[0-9]+[[:space:]]?$`).MatchString +var callSignRegex = regexp.MustCompile(`^[A-Z0-9]+$`).MatchString +var hdRegex = regexp.MustCompile(`hd|4k`) + // Track describes a single M3U segment. This struct includes m3u.Track as well as specific IPTV fields we want to get. type Track struct { *m3u.Track - SafeURI string `json:"URI"` - Catchup string `m3u:"catchup" json:",omitempty"` - CatchupDays string `m3u:"catchup-days" json:",omitempty"` - CatchupSource string `m3u:"catchup-source" json:",omitempty"` - GroupTitle string `m3u:"group-title" json:",omitempty"` - TvgID string `m3u:"tvg-id" json:",omitempty"` - TvgLogo string `m3u:"tvg-logo" json:",omitempty"` - TvgName string `m3u:"tvg-name" json:",omitempty"` - - XMLTVChannel xmlTVChannel `json:",omitempty"` - XMLTVProgrammes []xmltv.Programme `json:",omitempty"` + SafeURI string `json:"URI"` + Catchup string `m3u:"catchup" json:",omitempty"` + CatchupDays string `m3u:"catchup-days" json:",omitempty"` + CatchupSource string `m3u:"catchup-source" json:",omitempty"` + GroupTitle string `m3u:"group-title" json:",omitempty"` + TvgID string `m3u:"tvg-id" json:",omitempty"` + TvgLogo string `m3u:"tvg-logo" json:",omitempty"` + TvgName string `m3u:"tvg-name" json:",omitempty"` + TvgChannelNumber string `m3u:"tvg-chno" json:",omitempty"` + ChannelID string `m3u:"channel-id" json:",omitempty"` + + XMLTVChannel *xmlTVChannel `json:",omitempty"` + XMLTVProgrammes *[]xmltv.Programme `json:",omitempty"` } -// Channel returns a Channel struct for the given Track. -func (t *Track) Channel(number int, obfuscate bool) *HDHomeRunChannel { - var finalName string - if t.TvgName == "" { - finalName = t.Name - } else { - finalName = t.TvgName +func (t *Track) PrettyName() string { + if t.XMLTVChannel != nil { + return t.XMLTVChannel.LongName + } else if t.TvgName != "" { + return t.TvgName + } else if t.Track.Name != "" { + return t.Track.Name } - // base64 url - fullTrackURI := t.URI - if obfuscate { - trackURI := base64.StdEncoding.EncodeToString([]byte(t.URI)) - fullTrackURI = fmt.Sprintf("http://%s/stream/%s", opts.BaseAddress.String(), trackURI) - } - - // if strings.Contains(t.URI, ".m3u8") { - // log.Warnln("your .m3u contains .m3u8's. Plex has either stopped supporting m3u8 or it is a bug in a recent version - please use .ts! telly will automatically convert these in a future version. See telly github issue #108") - // } - - hd := false - if strings.Contains(strings.ToLower(t.Track.Raw), "hd") { - hd = true - } - - return &HDHomeRunChannel{ - GuideNumber: number, - GuideName: finalName, - URL: fullTrackURI, - HD: convertibleBoolean(hd), - - track: t, - } + return t.Name } // Playlist describes a single M3U playlist. @@ -77,6 +59,7 @@ type Playlist struct { Channels []HDHomeRunChannel TracksCount int FilteredTracksCount int + EPGProvided bool } // Filter will filter the raw m3u.Playlist m3u.Track slice into the Track slice of the Playlist. @@ -91,7 +74,7 @@ func (p *Playlist) Filter() error { return unmarshalErr } - if opts.Regex.MatchString(track.Name) == opts.RegexInclusive { + if opts.Regex.MatchString(track.Raw) == opts.RegexInclusive { p.Tracks = append(p.Tracks, track) } } @@ -129,7 +112,6 @@ type Lineup struct { StartingChannelNumber int channelNumber int - ObfuscateURL bool Refreshing bool LastRefreshed time.Time `json:",omitempty"` @@ -140,6 +122,9 @@ type Lineup struct { xmlTvSourceInfoURL []string xmlTvSourceInfoName []string xmlTvSourceDataURL []string + xmlTVChannelNumbers bool + + chanNumToURLMap map[string]string } // NewLineup returns a new Lineup for the given config struct. @@ -150,12 +135,13 @@ func NewLineup(opts config) *Lineup { } lineup := &Lineup{ + xmlTVChannelNumbers: opts.XMLTVChannelNumbers, + chanNumToURLMap: make(map[string]string), xmlTv: tv, xmlTvChannelMap: make(map[string]xmlTVChannel), StartingChannelNumber: opts.StartingChannel, channelNumber: opts.StartingChannel, - ObfuscateURL: !opts.DirectMode, - Refreshing: true, + Refreshing: false, LastRefreshed: time.Now(), } @@ -196,12 +182,12 @@ func (l *Lineup) AddPlaylist(plist string) error { } } - playlist, playlistErr := l.NewPlaylist(rawPlaylist, info) + playlist, playlistErr := l.NewPlaylist(rawPlaylist, info, (len(splitStr) > 1)) if playlistErr != nil { return playlistErr } - l.Playlists = append(l.Playlists, *playlist) + l.Playlists = append(l.Playlists, playlist) l.PlaylistsCount = len(l.Playlists) l.TracksCount = l.TracksCount + playlist.TracksCount l.FilteredTracksCount = l.FilteredTracksCount + playlist.FilteredTracksCount @@ -210,40 +196,56 @@ func (l *Lineup) AddPlaylist(plist string) error { } // NewPlaylist will return a new and filtered Playlist for the given m3u.Playlist and M3UFile. -func (l *Lineup) NewPlaylist(rawPlaylist *m3u.Playlist, info *M3UFile) (*Playlist, error) { - playlist := &Playlist{rawPlaylist, info, nil, nil, len(rawPlaylist.Tracks), 0} +func (l *Lineup) NewPlaylist(rawPlaylist *m3u.Playlist, info *M3UFile, hasEPG bool) (Playlist, error) { + playlist := Playlist{rawPlaylist, info, nil, nil, len(rawPlaylist.Tracks), 0, hasEPG} if filterErr := playlist.Filter(); filterErr != nil { log.WithError(filterErr).Errorln("error during filtering of channels, check your regex and try again") - return nil, filterErr + return playlist, filterErr } for idx, track := range playlist.Tracks { + tt, channelNumber, hd, ttErr := l.processTrack(hasEPG, track) + if ttErr != nil { + return playlist, ttErr + } - channelNumber := l.channelNumber - - if xmlChan, ok := l.xmlTvChannelMap[track.TvgID]; ok && !contains(l.channelsInXMLTv, track.TvgID) { - log.Infoln("found an entry in xmlTvChannelMap for", track.TvgID) - channelNumber = xmlChan.Number - l.channelsInXMLTv = append(l.channelsInXMLTv, track.TvgID) - track.XMLTVChannel = xmlChan - l.xmlTv.Channels = append(l.xmlTv.Channels, xmlChan.Original) - if xmlChan.Programmes != nil { - track.XMLTVProgrammes = xmlChan.Programmes - l.xmlTv.Programmes = append(l.xmlTv.Programmes, xmlChan.Programmes...) - } - playlist.Tracks[idx] = track + if hasEPG && tt.XMLTVChannel == nil { + log.Warnf("%s (#%d) is not being exposed to Plex because there was no EPG data found.", tt.Name, channelNumber) + continue } - channel := track.Channel(channelNumber, l.ObfuscateURL) + playlist.Tracks[idx] = *tt + + guideName := tt.PrettyName() - playlist.Channels = append(playlist.Channels, *channel) + log.Debugln("Assigning", channelNumber, l.channelNumber, "to", guideName) + + hdhr := HDHomeRunChannel{ + GuideNumber: channelNumber, + GuideName: guideName, + URL: fmt.Sprintf("http://%s/auto/v%d", opts.BaseAddress.String(), channelNumber), + HD: convertibleBoolean(hd), + DRM: convertibleBoolean(false), + } + + if !channelExists(playlist.Channels, hdhr) { + playlist.Channels = append(playlist.Channels, hdhr) + l.chanNumToURLMap[strconv.Itoa(channelNumber)] = tt.Track.URI + } if channelNumber == l.channelNumber { // Only increment lineup channel number if its for a channel that didnt have a XMLTV entry. l.channelNumber = l.channelNumber + 1 } + } + sort.Slice(l.xmlTv.Channels, func(i, j int) bool { + first, _ := strconv.Atoi(l.xmlTv.Channels[i].ID) + second, _ := strconv.Atoi(l.xmlTv.Channels[j].ID) + return first < second + }) + playlist.FilteredTracksCount = len(playlist.Tracks) exposedChannels.Add(float64(playlist.FilteredTracksCount)) log.Debugf("Added %d channels to the lineup", playlist.FilteredTracksCount) @@ -251,8 +253,48 @@ func (l *Lineup) NewPlaylist(rawPlaylist *m3u.Playlist, info *M3UFile) (*Playlis return playlist, nil } +func (l Lineup) processTrack(hasEPG bool, track Track) (*Track, int, bool, error) { + hd := hdRegex.MatchString(strings.ToLower(track.Track.Raw)) + channelNumber := l.channelNumber + if xmlChan, ok := l.xmlTvChannelMap[track.TvgID]; ok { + log.Debugln("found an entry in xmlTvChannelMap for", track.Name) + if l.xmlTVChannelNumbers && xmlChan.Number != 0 { + channelNumber = xmlChan.Number + } else { + xmlChan.Number = channelNumber + } + l.channelsInXMLTv = append(l.channelsInXMLTv, track.TvgID) + track.XMLTVChannel = &xmlChan + l.xmlTv.Channels = append(l.xmlTv.Channels, xmlChan.RemappedChannel(track)) + if xmlChan.Programmes != nil { + track.XMLTVProgrammes = &xmlChan.Programmes + for _, programme := range xmlChan.Programmes { + newProgramme := programme + for idx, title := range programme.Titles { + programme.Titles[idx].Value = strings.Replace(title.Value, " [New!]", "", -1) // Hardcoded fix for Vaders + } + newProgramme.Channel = strconv.Itoa(channelNumber) + if hd { + if newProgramme.Video == nil { + newProgramme.Video = &xmltv.Video{} + } + newProgramme.Video.Quality = "HDTV" + } + l.xmlTv.Programmes = append(l.xmlTv.Programmes, newProgramme) + } + } + } + + return &track, channelNumber, hd, nil +} + // Refresh will rescan all playlists for any channel changes. -func (l *Lineup) Refresh() error { +func (l Lineup) Refresh() error { + + if l.Refreshing { + log.Warnln("A refresh is already underway yet, another one was requested") + return nil + } log.Warnln("Refreshing the lineup!") @@ -272,6 +314,8 @@ func (l *Lineup) Refresh() error { } } + log.Infoln("Done refreshing the lineup!") + l.LastRefreshed = time.Now() l.Refreshing = false @@ -334,9 +378,6 @@ func (l *Lineup) getFile(path string) (io.Reader, string, error) { return file, transport, nil } -var channelNumberRegex = regexp.MustCompile(`^[0-9]+[[:space:]]?$`).MatchString -var callSignRegex = regexp.MustCompile(`^[A-Z0-9]+$`).MatchString - type xmlTVChannel struct { ID string Number int @@ -351,6 +392,18 @@ type xmlTVChannel struct { Original xmltv.Channel } +func (x *xmlTVChannel) RemappedChannel(t Track) xmltv.Channel { + newX := x.Original + newX.ID = strconv.Itoa(x.Number) + if t.TvgLogo != "" { + newX.Icons = append(newX.Icons, xmltv.Icon{Source: t.TvgLogo}) + } + if t.Track.Name != "" { + newX.DisplayNames = append(newX.DisplayNames, xmltv.CommonElement{Value: t.Track.Name}) + } + return newX +} + func (l *Lineup) processXMLTV(tv *xmltv.TV) (map[string]xmlTVChannel, error) { programmeMap := make(map[string][]xmltv.Programme) for _, programme := range tv.Programmes { @@ -358,30 +411,32 @@ func (l *Lineup) processXMLTV(tv *xmltv.TV) (map[string]xmlTVChannel, error) { } channelMap := make(map[string]xmlTVChannel, 0) - startManualNumber := 10000 for _, tvChann := range tv.Channels { - xTVChan := xmlTVChannel{ + xTVChan := &xmlTVChannel{ ID: tvChann.ID, Original: tvChann, } if programmes, ok := programmeMap[tvChann.ID]; ok { xTVChan.Programmes = programmes } + if channelNumberRegex(tvChann.ID) { + xTVChan.Number, _ = strconv.Atoi(tvChann.ID) + } displayNames := []string{} for _, displayName := range tvChann.DisplayNames { displayNames = append(displayNames, displayName.Value) } sort.StringSlice(displayNames).Sort() for i := 0; i < 10; i++ { - iterateDisplayNames(displayNames, &xTVChan) + iterateDisplayNames(displayNames, xTVChan) } - if xTVChan.Number == 0 { - xTVChan.Number = startManualNumber + 1 - startManualNumber = xTVChan.Number - xTVChan.NumberAssigned = true + channelMap[xTVChan.ID] = *xTVChan + // Duplicate this to first display-name just in case the M3U and XMLTV differ significantly. + for _, dn := range tvChann.DisplayNames { + channelMap[dn.Value] = *xTVChan } - channelMap[xTVChan.ID] = xTVChan } + return channelMap, nil } @@ -408,9 +463,9 @@ func iterateDisplayNames(displayNames []string, xTVChan *xmlTVChannel) { } } -func contains(s []string, e string) bool { +func channelExists(s []HDHomeRunChannel, e HDHomeRunChannel) bool { for _, a := range s { - if a == e { + if a.GuideName == e.GuideName { return true } } diff --git a/main.go b/main.go index 7ae1f7d..cf12e1a 100644 --- a/main.go +++ b/main.go @@ -2,6 +2,7 @@ package main import ( "fmt" + "os" "regexp" "strings" @@ -14,8 +15,15 @@ import ( var ( namespace = "telly" namespaceWithVersion = fmt.Sprintf("%s %s", namespace, version.Version) - log = logrus.New() - opts = config{} + log = &logrus.Logger{ + Out: os.Stderr, + Formatter: &logrus.TextFormatter{ + FullTimestamp: true, + }, + Hooks: make(logrus.LevelHooks), + Level: logrus.DebugLevel, + } + opts = config{} exposedChannels = prometheus.NewGauge( prometheus.GaugeOpts{ @@ -55,7 +63,7 @@ func main() { kingpin.Flag("discovery.ssdp", "Turn on SSDP announcement of telly to the local network $(TELLY_DISCOVERY_SSDP)").Envar("TELLY_DISCOVERY_SSDP").Default("true").BoolVar(&opts.SSDP) // Regex/filtering flags - kingpin.Flag("filter.regex-inclusive", "Whether the provided regex is inclusive (whitelisting) or exclusive (blacklisting). If true (--filter.regex-inclusive), only channels matching the provided regex pattern will be exposed. If false (--no-filter.regex-inclusive), only channels NOT matching the provided pattern will be exposed. $(TELLY_FILTER_REGEX_MODE)").Envar("TELLY_FILTER_REGEX_MODE").Default("false").BoolVar(&opts.RegexInclusive) + kingpin.Flag("filter.regex-inclusive", "Whether the provided regex is inclusive (whitelisting) or exclusive (blacklisting). If true (--filter.regex-inclusive), only channels matching the provided regex pattern will be exposed. If false (--no-filter.regex-inclusive), only channels NOT matching the provided pattern will be exposed. $(TELLY_FILTER_REGEX_INCLUSIVE)").Envar("TELLY_FILTER_REGEX_INCLUSIVE").Default("false").BoolVar(&opts.RegexInclusive) kingpin.Flag("filter.regex", "Use regex to filter for channels that you want. A basic example would be .*UK.*. $(TELLY_FILTER_REGEX)").Envar("TELLY_FILTER_REGEX").Default(".*").RegexpVar(&opts.Regex) // Web flags @@ -67,10 +75,10 @@ func main() { kingpin.Flag("log.requests", "Log HTTP requests $(TELLY_LOG_REQUESTS)").Envar("TELLY_LOG_REQUESTS").Default("false").BoolVar(&opts.LogRequests) // IPTV flags - kingpin.Flag("iptv.playlist", "Location of playlist M3U file. Can be on disk or a URL. $(TELLY_IPTV_PLAYLIST)").Envar("TELLY_IPTV_PLAYLIST").Default("iptv.m3u").StringsVar(&opts.Playlists) + kingpin.Flag("iptv.playlist", "Path to an M3U file and optionally, a XMLTV file. Combine both strings with a semi-colon (;) for this functionality. Paths can be on disk or a URL. This flag can be used multiple times. $(TELLY_IPTV_PLAYLIST)").Envar("TELLY_IPTV_PLAYLIST").Default("iptv.m3u").StringsVar(&opts.Playlists) kingpin.Flag("iptv.streams", "Number of concurrent streams allowed $(TELLY_IPTV_STREAMS)").Envar("TELLY_IPTV_STREAMS").Default("1").IntVar(&opts.ConcurrentStreams) - kingpin.Flag("iptv.direct", "If true, stream URLs will not be obfuscated to hide them from Plex. $(TELLY_IPTV_DIRECT)").Envar("TELLY_IPTV_DIRECT").Default("false").BoolVar(&opts.DirectMode) kingpin.Flag("iptv.starting-channel", "The channel number to start exposing from. $(TELLY_IPTV_STARTING_CHANNEL)").Envar("TELLY_IPTV_STARTING_CHANNEL").Default("10000").IntVar(&opts.StartingChannel) + kingpin.Flag("iptv.xmltv-channels", "Use channel numbers discovered via XMLTV file, if provided. $(TELLY_IPTV_XMLTV_CHANNELS)").Envar("TELLY_IPTV_XMLTV_CHANNELS").Default("true").BoolVar(&opts.XMLTVChannelNumbers) kingpin.Version(version.Print("telly")) kingpin.HelpFlag.Short('h') diff --git a/routes.go b/routes.go index b952698..0350426 100644 --- a/routes.go +++ b/routes.go @@ -1,7 +1,7 @@ package main import ( - "encoding/base64" + "encoding/xml" "fmt" "net/http" "sort" @@ -35,12 +35,24 @@ func serve(opts config) { router.GET("/", deviceXML(upnp)) router.GET("/discover.json", discovery(discoveryData)) - router.GET("/lineup_status.json", lineupStatus(LineupStatus{ - ScanInProgress: convertibleBoolean(opts.lineup.Refreshing), - ScanPossible: convertibleBoolean(true), - Source: "Cable", - SourceList: []string{"Cable"}, - })) + router.GET("/lineup_status.json", func(c *gin.Context) { + payload := LineupStatus{ + ScanInProgress: convertibleBoolean(false), + ScanPossible: convertibleBoolean(true), + Source: "Cable", + SourceList: []string{"Cable"}, + } + if opts.lineup.Refreshing { + payload = LineupStatus{ + ScanInProgress: convertibleBoolean(true), + // Gotta fake out Plex. + Progress: 50, + Found: 50, + } + } + + c.JSON(http.StatusOK, payload) + }) router.POST("/lineup.post", func(c *gin.Context) { scanAction := c.Query("scan") if scanAction == "start" { @@ -57,7 +69,7 @@ func serve(opts config) { }) router.GET("/device.xml", deviceXML(upnp)) router.GET("/lineup.json", lineup(opts.lineup)) - router.GET("/stream/:channelID", stream) + router.GET("/auto/:channelID", stream(opts.lineup)) router.GET("/epg.xml", xmlTV(opts.lineup)) router.GET("/debug.json", func(c *gin.Context) { c.JSON(http.StatusOK, opts.lineup) @@ -107,25 +119,22 @@ func lineup(lineup *Lineup) gin.HandlerFunc { func xmlTV(lineup *Lineup) gin.HandlerFunc { return func(c *gin.Context) { - c.XML(http.StatusOK, lineup.xmlTv) + buf, _ := xml.MarshalIndent(lineup.xmlTv, "", "\t") + c.Data(http.StatusOK, "application/xml", []byte(xml.Header+``+"\n"+string(buf))) } } -func stream(c *gin.Context) { - - channelID := c.Param("channelID") - - log.Debugf("Parsing URI %s to %s", c.Request.RequestURI, channelID) +func stream(lineup *Lineup) gin.HandlerFunc { + return func(c *gin.Context) { + channelID := c.Param("channelID")[1:] - decodedStreamURI, decodeErr := base64.StdEncoding.DecodeString(channelID) - if decodeErr != nil { - log.WithError(decodeErr).Errorf("Invalid base64: %s", channelID) - c.AbortWithError(http.StatusBadRequest, decodeErr) - return + if url, ok := lineup.chanNumToURLMap[channelID]; ok { + log.Infof("Serving channel number %s", channelID) + c.Redirect(http.StatusMovedPermanently, url) + return + } + c.AbortWithError(http.StatusNotFound, fmt.Errorf("unknown channel number %s", channelID)) } - - log.Debugln("Redirecting to:", string(decodedStreamURI)) - c.Redirect(http.StatusMovedPermanently, string(decodedStreamURI)) } func ginrus() gin.HandlerFunc { diff --git a/structs.go b/structs.go index c2d6110..23c014c 100644 --- a/structs.go +++ b/structs.go @@ -13,10 +13,10 @@ type config struct { RegexInclusive bool Regex *regexp.Regexp - DirectMode bool - Playlists []string - ConcurrentStreams int - StartingChannel int + Playlists []string + ConcurrentStreams int + StartingChannel int + XMLTVChannelNumbers bool DeviceAuth string DeviceID int @@ -87,9 +87,11 @@ func (d *DiscoveryData) UPNP() UPNP { // LineupStatus exposes the status of the channel lineup. type LineupStatus struct { ScanInProgress convertibleBoolean - ScanPossible convertibleBoolean - Source string - SourceList []string + ScanPossible convertibleBoolean `json:",omitempty"` + Source string `json:",omitempty"` + SourceList []string `json:",omitempty"` + Progress int `json:",omitempty"` // Percent complete + Found int `json:",omitempty"` // Number of found channels } type upnpVersion struct { diff --git a/xmltv/xmltv.go b/xmltv/xmltv.go index 6795f43..911c275 100644 --- a/xmltv/xmltv.go +++ b/xmltv/xmltv.go @@ -35,6 +35,7 @@ func (t *Time) UnmarshalXMLAttr(attr xml.Attr) error { // TV is the root element. type TV struct { + XMLName xml.Name `xml:"tv" json:"-"` Channels []Channel `xml:"channel" json:"channels"` Programmes []Programme `xml:"programme" json:"programmes"` Date string `xml:"date,attr,omitempty" json:"date,omitempty"` @@ -88,7 +89,7 @@ type Programme struct { PreviouslyShown *PreviouslyShown `xml:"previously-shown,omitempty" json:"previously_shown,omitempty"` Premiere *CommonElement `xml:"premiere,omitempty" json:"premiere,omitempty"` LastChance *CommonElement `xml:"last-chance,omitempty" json:"last_chance,omitempty"` - New ElementPresent `xml:"new" json:"new"` + New ElementPresent `xml:"new>placeholder,omitempty" json:"new"` Subtitles []Subtitle `xml:"subtitles,omitempty" json:"subtitles,omitempty"` Ratings []Rating `xml:"rating,omitempty" json:"ratings,omitempty"` StarRatings []Rating `xml:"star-rating,omitempty" json:"star_ratings,omitempty"` @@ -101,6 +102,10 @@ type Programme struct { Videoplus string `xml:"videoplus,attr,omitempty" json:"videoplus,omitempty"` Channel string `xml:"channel,attr" json:"channel"` Clumpidx string `xml:"clumpidx,attr,omitempty" json:"clumpidx,omitempty"` + + // These fields are outside of the XMLTV spec. + // LCN is the local channel number. Plex will show it in place of the channel ID if it exists. + LCN int `xml:"lcn,attr" json:"lcn,omitempty"` } // CommonElement element structure that is common, i.e. Italy @@ -112,6 +117,11 @@ type CommonElement struct { // ElementPresent used to determine if element is present or not type ElementPresent bool +// MarshalXML used to determine if the element is present or not. see https://stackoverflow.com/a/46516243 +func (c *ElementPresent) MarshalXML(e *xml.Encoder, start xml.StartElement) error { + return e.EncodeElement(*c, start) +} + // UnmarshalXML used to determine if the element is present or not. see https://stackoverflow.com/a/46516243 func (c *ElementPresent) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { var v string @@ -124,9 +134,9 @@ func (c *ElementPresent) UnmarshalXML(d *xml.Decoder, start xml.StartElement) er // Icon associated with the element that contains it type Icon struct { - Source string `xml:"src,attr" json:"source"` - Width int `xml:"width,attr" json:"width"` - Height int `xml:"height,attr" json:"height"` + Source string `xml:"src,attr" json:"source"` + Width int `xml:"width,attr,omitempty" json:"width,omitempty"` + Height int `xml:"height,attr,omitempty" json:"height,omitempty"` } // Credits for the programme From 952cbc74b1ec6f8327ce8889053b2fd6c3960ebf Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Tue, 14 Aug 2018 17:01:54 -0700 Subject: [PATCH 006/114] Checkpoint before redoing Lineup --- lineup.go | 72 ++++++++++++++++--------- main.go | 115 +++++++++++++++++++++++++--------------- providers/eternal.go | 4 ++ providers/hellraiser.go | 4 ++ providers/iptv-epg.go | 4 ++ providers/main.go | 97 +++++++++++++++++++++++++++++++++ providers/tnt.go | 4 ++ providers/vaders.go | 72 +++++++++++++++++++++++++ routes.go | 30 +++++------ structs.go | 67 +++++++++++------------ utils.go | 34 ++++++++++++ 11 files changed, 386 insertions(+), 117 deletions(-) create mode 100644 providers/eternal.go create mode 100644 providers/hellraiser.go create mode 100644 providers/iptv-epg.go create mode 100644 providers/main.go create mode 100644 providers/tnt.go create mode 100644 providers/vaders.go create mode 100644 utils.go diff --git a/lineup.go b/lineup.go index bbfbb87..4b3badb 100644 --- a/lineup.go +++ b/lineup.go @@ -12,7 +12,9 @@ import ( "strings" "time" + "github.com/spf13/viper" "github.com/tombowditch/telly/m3u" + "github.com/tombowditch/telly/providers" "github.com/tombowditch/telly/xmltv" ) @@ -74,7 +76,7 @@ func (p *Playlist) Filter() error { return unmarshalErr } - if opts.Regex.MatchString(track.Raw) == opts.RegexInclusive { + if GetStringAsRegex("filter.regexstr").MatchString(track.Raw) == viper.GetBool("filter.regexinclusive") { p.Tracks = append(p.Tracks, track) } } @@ -105,6 +107,8 @@ type HDHomeRunChannel struct { // Lineup is a collection of tracks type Lineup struct { + Providers []providers.Provider + Playlists []Playlist PlaylistsCount int TracksCount int @@ -128,32 +132,46 @@ type Lineup struct { } // NewLineup returns a new Lineup for the given config struct. -func NewLineup(opts config) *Lineup { +func NewLineup() *Lineup { tv := xmltv.TV{ GeneratorInfoName: namespaceWithVersion, GeneratorInfoURL: "https://github.com/tombowditch/telly", } lineup := &Lineup{ - xmlTVChannelNumbers: opts.XMLTVChannelNumbers, + xmlTVChannelNumbers: viper.GetBool("iptv.xmltv-channels"), chanNumToURLMap: make(map[string]string), xmlTv: tv, xmlTvChannelMap: make(map[string]xmlTVChannel), - StartingChannelNumber: opts.StartingChannel, - channelNumber: opts.StartingChannel, + StartingChannelNumber: viper.GetInt("iptv.starting-channel"), + channelNumber: viper.GetInt("iptv.starting-channel"), Refreshing: false, LastRefreshed: time.Now(), } + var cfgs []providers.Configuration + + if unmarshalErr := viper.UnmarshalKey("source", &cfgs); unmarshalErr != nil { + log.WithError(unmarshalErr).Panicln("Unable to unmarshal source configuration to slice of providers.Configuration, check your configuration!") + } + + for _, cfg := range cfgs { + log.Infoln("Adding provider", cfg.Name) + provider, providerErr := cfg.GetProvider() + if providerErr != nil { + panic(providerErr) + } + if addErr := lineup.AddProvider(provider); addErr != nil { + log.WithError(addErr).Panicln("error adding new provider to lineup") + } + } + return lineup } -// AddPlaylist adds a new playlist to the Lineup. -func (l *Lineup) AddPlaylist(plist string) error { - // Attempt to split the string by semi colon for complex config passing with m3uPath,xmlPath,name - splitStr := strings.Split(plist, ";") - path := splitStr[0] - reader, info, readErr := l.getM3U(path) +// AddProvider adds a new Provider to the Lineup. +func (l *Lineup) AddProvider(provider providers.Provider) error { + reader, info, readErr := l.getM3U(provider.PlaylistURL()) if readErr != nil { log.WithError(readErr).Errorln("error getting m3u") return readErr @@ -165,8 +183,8 @@ func (l *Lineup) AddPlaylist(plist string) error { return err } - if len(splitStr) > 1 { - epg, epgReadErr := l.getXMLTV(splitStr[1]) + if provider.EPGURL() != "" { + epg, epgReadErr := l.getXMLTV(provider.EPGURL()) if epgReadErr != nil { log.WithError(epgReadErr).Errorln("error getting XMLTV") return epgReadErr @@ -182,7 +200,7 @@ func (l *Lineup) AddPlaylist(plist string) error { } } - playlist, playlistErr := l.NewPlaylist(rawPlaylist, info, (len(splitStr) > 1)) + playlist, playlistErr := l.NewPlaylist(provider, rawPlaylist, info) if playlistErr != nil { return playlistErr } @@ -196,7 +214,8 @@ func (l *Lineup) AddPlaylist(plist string) error { } // NewPlaylist will return a new and filtered Playlist for the given m3u.Playlist and M3UFile. -func (l *Lineup) NewPlaylist(rawPlaylist *m3u.Playlist, info *M3UFile, hasEPG bool) (Playlist, error) { +func (l *Lineup) NewPlaylist(provider providers.Provider, rawPlaylist *m3u.Playlist, info *M3UFile) (Playlist, error) { + hasEPG := provider.EPGURL() != "" playlist := Playlist{rawPlaylist, info, nil, nil, len(rawPlaylist.Tracks), 0, hasEPG} if filterErr := playlist.Filter(); filterErr != nil { @@ -205,7 +224,7 @@ func (l *Lineup) NewPlaylist(rawPlaylist *m3u.Playlist, info *M3UFile, hasEPG bo } for idx, track := range playlist.Tracks { - tt, channelNumber, hd, ttErr := l.processTrack(hasEPG, track) + tt, channelNumber, hd, ttErr := l.processTrack(provider, track) if ttErr != nil { return playlist, ttErr } @@ -224,7 +243,7 @@ func (l *Lineup) NewPlaylist(rawPlaylist *m3u.Playlist, info *M3UFile, hasEPG bo hdhr := HDHomeRunChannel{ GuideNumber: channelNumber, GuideName: guideName, - URL: fmt.Sprintf("http://%s/auto/v%d", opts.BaseAddress.String(), channelNumber), + URL: fmt.Sprintf("http://%s/auto/v%d", viper.GetString("web.base-address"), channelNumber), HD: convertibleBoolean(hd), DRM: convertibleBoolean(false), } @@ -253,9 +272,11 @@ func (l *Lineup) NewPlaylist(rawPlaylist *m3u.Playlist, info *M3UFile, hasEPG bo return playlist, nil } -func (l Lineup) processTrack(hasEPG bool, track Track) (*Track, int, bool, error) { +func (l Lineup) processTrack(provider providers.Provider, track Track) (*Track, int, bool, error) { + hd := hdRegex.MatchString(strings.ToLower(track.Track.Raw)) channelNumber := l.channelNumber + if xmlChan, ok := l.xmlTvChannelMap[track.TvgID]; ok { log.Debugln("found an entry in xmlTvChannelMap for", track.Name) if l.xmlTVChannelNumbers && xmlChan.Number != 0 { @@ -308,11 +329,12 @@ func (l Lineup) Refresh() error { l.FilteredTracksCount = 0 l.StartingChannelNumber = 0 - for _, playlist := range existingPlaylists { - if addErr := l.AddPlaylist(playlist.M3UFile.Path); addErr != nil { - return addErr - } - } + // FIXME: Re-implement AddProvider to use a provider. + // for _, playlist := range existingPlaylists { + // if addErr := l.AddProvider(playlist.M3UFile.Path); addErr != nil { + // return addErr + // } + // } log.Infoln("Done refreshing the lineup!") @@ -428,7 +450,7 @@ func (l *Lineup) processXMLTV(tv *xmltv.TV) (map[string]xmlTVChannel, error) { } sort.StringSlice(displayNames).Sort() for i := 0; i < 10; i++ { - iterateDisplayNames(displayNames, xTVChan) + extractDisplayNames(displayNames, xTVChan) } channelMap[xTVChan.ID] = *xTVChan // Duplicate this to first display-name just in case the M3U and XMLTV differ significantly. @@ -440,7 +462,7 @@ func (l *Lineup) processXMLTV(tv *xmltv.TV) (map[string]xmlTVChannel, error) { return channelMap, nil } -func iterateDisplayNames(displayNames []string, xTVChan *xmlTVChannel) { +func extractDisplayNames(displayNames []string, xTVChan *xmlTVChannel) { for _, displayName := range displayNames { if channelNumberRegex(displayName) { if chanNum, chanNumErr := strconv.Atoi(displayName); chanNumErr == nil { diff --git a/main.go b/main.go index cf12e1a..7746d66 100644 --- a/main.go +++ b/main.go @@ -1,7 +1,10 @@ package main import ( + "encoding/json" + fflag "flag" "fmt" + "net" "os" "regexp" "strings" @@ -9,7 +12,8 @@ import ( "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/common/version" "github.com/sirupsen/logrus" - kingpin "gopkg.in/alecthomas/kingpin.v2" + flag "github.com/spf13/pflag" + "github.com/spf13/viper" ) var ( @@ -53,76 +57,103 @@ var ( func main() { // Discovery flags - kingpin.Flag("discovery.device-id", "8 digits used to uniquely identify the device. $(TELLY_DISCOVERY_DEVICE_ID)").Envar("TELLY_DISCOVERY_DEVICE_ID").Default("12345678").IntVar(&opts.DeviceID) - kingpin.Flag("discovery.device-friendly-name", "Name exposed via discovery. Useful if you are running two instances of telly and want to differentiate between them $(TELLY_DISCOVERY_DEVICE_FRIENDLY_NAME)").Envar("TELLY_DISCOVERY_DEVICE_FRIENDLY_NAME").Default("telly").StringVar(&opts.FriendlyName) - kingpin.Flag("discovery.device-auth", "Only change this if you know what you're doing $(TELLY_DISCOVERY_DEVICE_AUTH)").Envar("TELLY_DISCOVERY_DEVICE_AUTH").Default("telly123").Hidden().StringVar(&opts.DeviceAuth) - kingpin.Flag("discovery.device-manufacturer", "Manufacturer exposed via discovery. $(TELLY_DISCOVERY_DEVICE_MANUFACTURER)").Envar("TELLY_DISCOVERY_DEVICE_MANUFACTURER").Default("Silicondust").StringVar(&opts.Manufacturer) - kingpin.Flag("discovery.device-model-number", "Model number exposed via discovery. $(TELLY_DISCOVERY_DEVICE_MODEL_NUMBER)").Envar("TELLY_DISCOVERY_DEVICE_MODEL_NUMBER").Default("HDTC-2US").StringVar(&opts.ModelNumber) - kingpin.Flag("discovery.device-firmware-name", "Firmware name exposed via discovery. $(TELLY_DISCOVERY_DEVICE_FIRMWARE_NAME)").Envar("TELLY_DISCOVERY_DEVICE_FIRMWARE_NAME").Default("hdhomeruntc_atsc").StringVar(&opts.FirmwareName) - kingpin.Flag("discovery.device-firmware-version", "Firmware version exposed via discovery. $(TELLY_DISCOVERY_DEVICE_FIRMWARE_VERSION)").Envar("TELLY_DISCOVERY_DEVICE_FIRMWARE_VERSION").Default("20150826").StringVar(&opts.FirmwareVersion) - kingpin.Flag("discovery.ssdp", "Turn on SSDP announcement of telly to the local network $(TELLY_DISCOVERY_SSDP)").Envar("TELLY_DISCOVERY_SSDP").Default("true").BoolVar(&opts.SSDP) + flag.Int("discovery.device-id", 12345678, "8 digits used to uniquely identify the device. $(TELLY_DISCOVERY_DEVICE_ID)") + flag.String("discovery.device-friendly-name", "telly", "Name exposed via discovery. Useful if you are running two instances of telly and want to differentiate between them $(TELLY_DISCOVERY_DEVICE_FRIENDLY_NAME)") + flag.String("discovery.device-auth", "telly123", "Only change this if you know what you're doing $(TELLY_DISCOVERY_DEVICE_AUTH)") + flag.String("discovery.device-manufacturer", "Silicondust", "Manufacturer exposed via discovery. $(TELLY_DISCOVERY_DEVICE_MANUFACTURER)") + flag.String("discovery.device-model-number", "HDTC-2US", "Model number exposed via discovery. $(TELLY_DISCOVERY_DEVICE_MODEL_NUMBER)") + flag.String("discovery.device-firmware-name", "hdhomeruntc_atsc", "Firmware name exposed via discovery. $(TELLY_DISCOVERY_DEVICE_FIRMWARE_NAME)") + flag.String("discovery.device-firmware-version", "20150826", "Firmware version exposed via discovery. $(TELLY_DISCOVERY_DEVICE_FIRMWARE_VERSION)") + flag.Bool("discovery.ssdp", true, "Turn on SSDP announcement of telly to the local network $(TELLY_DISCOVERY_SSDP)") // Regex/filtering flags - kingpin.Flag("filter.regex-inclusive", "Whether the provided regex is inclusive (whitelisting) or exclusive (blacklisting). If true (--filter.regex-inclusive), only channels matching the provided regex pattern will be exposed. If false (--no-filter.regex-inclusive), only channels NOT matching the provided pattern will be exposed. $(TELLY_FILTER_REGEX_INCLUSIVE)").Envar("TELLY_FILTER_REGEX_INCLUSIVE").Default("false").BoolVar(&opts.RegexInclusive) - kingpin.Flag("filter.regex", "Use regex to filter for channels that you want. A basic example would be .*UK.*. $(TELLY_FILTER_REGEX)").Envar("TELLY_FILTER_REGEX").Default(".*").RegexpVar(&opts.Regex) + flag.Bool("filter.regex-inclusive", false, "Whether the provided regex is inclusive (whitelisting) or exclusive (blacklisting). If true (--filter.regex-inclusive), only channels matching the provided regex pattern will be exposed. If false (--no-filter.regex-inclusive), only channels NOT matching the provided pattern will be exposed. $(TELLY_FILTER_REGEX_INCLUSIVE)") + flag.String("filter.regex", ".*", "Use regex to filter for channels that you want. A basic example would be .*UK.*. $(TELLY_FILTER_REGEX)") // Web flags - kingpin.Flag("web.listen-address", "Address to listen on for web interface and telemetry $(TELLY_WEB_LISTEN_ADDRESS)").Envar("TELLY_WEB_LISTEN_ADDRESS").Default("localhost:6077").TCPVar(&opts.ListenAddress) - kingpin.Flag("web.base-address", "The address to expose via discovery. Useful with reverse proxy $(TELLY_WEB_BASE_ADDRESS)").Envar("TELLY_WEB_BASE_ADDRESS").Default("localhost:6077").TCPVar(&opts.BaseAddress) + flag.String("web.listen-address", "localhost:6077", "Address to listen on for web interface and telemetry $(TELLY_WEB_LISTEN_ADDRESS)") + flag.String("web.base-address", "localhost:6077", "The address to expose via discovery. Useful with reverse proxy $(TELLY_WEB_BASE_ADDRESS)") // Log flags - kingpin.Flag("log.level", "Only log messages with the given severity or above. Valid levels: [debug, info, warn, error, fatal] $(TELLY_LOG_LEVEL)").Envar("TELLY_LOG_LEVEL").Default(logrus.InfoLevel.String()).StringVar(&opts.LogLevel) - kingpin.Flag("log.requests", "Log HTTP requests $(TELLY_LOG_REQUESTS)").Envar("TELLY_LOG_REQUESTS").Default("false").BoolVar(&opts.LogRequests) + flag.String("log.level", logrus.InfoLevel.String(), "Only log messages with the given severity or above. Valid levels: [debug, info, warn, error, fatal] $(TELLY_LOG_LEVEL)") + flag.Bool("log.requests", false, "Log HTTP requests $(TELLY_LOG_REQUESTS)") // IPTV flags - kingpin.Flag("iptv.playlist", "Path to an M3U file and optionally, a XMLTV file. Combine both strings with a semi-colon (;) for this functionality. Paths can be on disk or a URL. This flag can be used multiple times. $(TELLY_IPTV_PLAYLIST)").Envar("TELLY_IPTV_PLAYLIST").Default("iptv.m3u").StringsVar(&opts.Playlists) - kingpin.Flag("iptv.streams", "Number of concurrent streams allowed $(TELLY_IPTV_STREAMS)").Envar("TELLY_IPTV_STREAMS").Default("1").IntVar(&opts.ConcurrentStreams) - kingpin.Flag("iptv.starting-channel", "The channel number to start exposing from. $(TELLY_IPTV_STARTING_CHANNEL)").Envar("TELLY_IPTV_STARTING_CHANNEL").Default("10000").IntVar(&opts.StartingChannel) - kingpin.Flag("iptv.xmltv-channels", "Use channel numbers discovered via XMLTV file, if provided. $(TELLY_IPTV_XMLTV_CHANNELS)").Envar("TELLY_IPTV_XMLTV_CHANNELS").Default("true").BoolVar(&opts.XMLTVChannelNumbers) - - kingpin.Version(version.Print("telly")) - kingpin.HelpFlag.Short('h') - kingpin.Parse() + flag.String("iptv.playlist", "", "Path to an M3U file on disk or at a URL. $(TELLY_IPTV_PLAYLIST)") + flag.Int("iptv.streams", 1, "Number of concurrent streams allowed $(TELLY_IPTV_STREAMS)") + flag.Int("iptv.starting-channel", 10000, "The channel number to start exposing from. $(TELLY_IPTV_STARTING_CHANNEL)") + flag.Bool("iptv.xmltv-channels", true, "Use channel numbers discovered via XMLTV file, if provided. $(TELLY_IPTV_XMLTV_CHANNELS)") + + flag.CommandLine.AddGoFlagSet(fflag.CommandLine) + flag.Parse() + viper.BindPFlags(flag.CommandLine) + viper.SetConfigName("telly.config") // name of config file (without extension) + viper.AddConfigPath("/etc/telly/") // path to look for the config file in + viper.AddConfigPath("$HOME/.telly") // call multiple times to add many search paths + viper.AddConfigPath(".") // optionally look for config in the working directory + viper.SetEnvPrefix(namespace) + viper.AutomaticEnv() + err := viper.ReadInConfig() // Find and read the config file + if err != nil { // Handle errors reading the config file + if _, ok := err.(viper.ConfigFileNotFoundError); !ok { + log.WithError(err).Panicln("fatal error while reading config file:") + } + } log.Infoln("Starting telly", version.Info()) log.Infoln("Build context", version.BuildContext()) prometheus.MustRegister(version.NewCollector("telly"), exposedChannels) - level, parseLevelErr := logrus.ParseLevel(opts.LogLevel) + level, parseLevelErr := logrus.ParseLevel(viper.GetString("log.level")) if parseLevelErr != nil { log.WithError(parseLevelErr).Panicln("error setting log level!") } log.SetLevel(level) - opts.FriendlyName = fmt.Sprintf("HDHomerun (%s)", opts.FriendlyName) - opts.DeviceUUID = fmt.Sprintf("%d-AE2A-4E54-BBC9-33AF7D5D6A92", opts.DeviceID) + if log.Level == logrus.DebugLevel { + js, _ := json.MarshalIndent(viper.AllSettings(), "", " ") + log.Debugf("Loaded configuration %s", js) + } - if opts.BaseAddress.IP.IsUnspecified() { - log.Panicln("base URL is set to 0.0.0.0, this will not work. please use the --web.base-address option and set it to the (local) ip address telly is running on.") + if viper.IsSet("filter.regexstr") { + if _, regexErr := regexp.Compile(viper.GetString("filter.regex")); regexErr != nil { + log.WithError(regexErr).Panicln("Error when compiling regex, is it valid?") + } } - if opts.ListenAddress.IP.IsUnspecified() && opts.BaseAddress.IP.IsLoopback() { - log.Warnln("You are listening on all interfaces but your base URL is localhost (meaning Plex will try and load localhost to access your streams) - is this intended?") + var addrErr error + if _, addrErr = net.ResolveTCPAddr("tcp", viper.GetString("web.listenaddress")); addrErr != nil { + log.WithError(addrErr).Panic("Error when parsing Listen address, please check the address and try again.") + return + } + if _, addrErr = net.ResolveTCPAddr("tcp", viper.GetString("web.base-address")); addrErr != nil { + log.WithError(addrErr).Panic("Error when parsing Base addresses, please check the address and try again.") + return } - if len(opts.Playlists) == 1 && opts.Playlists[0] == "iptv.m3u" { - log.Warnln("using default m3u option, 'iptv.m3u'. launch telly with the --iptv.playlist=yourfile.m3u option to change this!") + if GetTCPAddr("web.base-address").IP.IsUnspecified() { + log.Panicln("base URL is set to 0.0.0.0, this will not work. please use the --web.baseaddress option and set it to the (local) ip address telly is running on.") } - opts.lineup = NewLineup(opts) + if GetTCPAddr("web.listenaddress").IP.IsUnspecified() && GetTCPAddr("web.base-address").IP.IsLoopback() { + log.Warnln("You are listening on all interfaces but your base URL is localhost (meaning Plex will try and load localhost to access your streams) - is this intended?") + } - for _, playlistPath := range opts.Playlists { - if addErr := opts.lineup.AddPlaylist(playlistPath); addErr != nil { - log.WithError(addErr).Panicln("error adding new playlist to lineup") - } + viper.Set("discovery.device-friendly-name", fmt.Sprintf("HDHomerun (%s)", viper.GetString("discovery.device-friendly-name"))) + viper.Set("discovery.device-uuid", fmt.Sprintf("%d-AE2A-4E54-BBC9-33AF7D5D6A92", viper.GetInt("discovery.device-id"))) + + if flag.Lookup("iptv.playlist").Changed { + viper.Set("playlists.default.m3u", flag.Lookup("iptv.playlist").Value.String()) } - log.Infof("Loaded %d channels into the lineup", opts.lineup.FilteredTracksCount) + lineup := NewLineup() + + log.Infof("Loaded %d channels into the lineup", lineup.FilteredTracksCount) - // if opts.lineup.FilteredTracksCount > 420 { - // log.Panicln("telly has loaded more than 420 channels into the lineup. Plex does not deal well with more than this amount and will more than likely hang when trying to fetch channels. You must use regular expressions to filter out channels. You can also start another Telly instance.") - // } + if lineup.FilteredTracksCount > 420 { + log.Panicf("telly has loaded more than 420 channels (%d) into the lineup. Plex does not deal well with more than this amount and will more than likely hang when trying to fetch channels. You must use regular expressions to filter out channels. You can also start another Telly instance.", lineup.FilteredTracksCount) + } - serve(opts) + serve(lineup) } diff --git a/providers/eternal.go b/providers/eternal.go new file mode 100644 index 0000000..fc59a4f --- /dev/null +++ b/providers/eternal.go @@ -0,0 +1,4 @@ +package providers + +// M3U:http://live.eternaltv.net:25461/get.php?username=xxxxxxx&password=xxxxxx&output=ts&type=m3u_plus +// XMLTV: http://live.eternaltv.net:25461/xmltv.php?username=xxxxx&password=xxxxx&type=m3u_plus&output=ts diff --git a/providers/hellraiser.go b/providers/hellraiser.go new file mode 100644 index 0000000..de264a8 --- /dev/null +++ b/providers/hellraiser.go @@ -0,0 +1,4 @@ +package providers + +// Playlist URL: http://liquidit.info:8080/get.php?username=xxxx&password=xxxxxxx&type=m3u_plus&output=ts +// XMLTV URL: http://liquidit.info:8080/xmltv.php?username=xxxxxx&password=xxxxxx diff --git a/providers/iptv-epg.go b/providers/iptv-epg.go new file mode 100644 index 0000000..d1af649 --- /dev/null +++ b/providers/iptv-epg.go @@ -0,0 +1,4 @@ +package providers + +// M3U: http://iptv-epg.com/.m3u +// XMLTV: http://iptv-epg.com/.xml diff --git a/providers/main.go b/providers/main.go new file mode 100644 index 0000000..ad7bbe3 --- /dev/null +++ b/providers/main.go @@ -0,0 +1,97 @@ +package providers + +import ( + "fmt" + "regexp" + "strings" + + log "github.com/sirupsen/logrus" + "github.com/tombowditch/telly/m3u" +) + +var channelNumberExtractor = regexp.MustCompile(`/(\d+).(ts|.*.m3u8)`).FindAllStringSubmatch + +type Configuration struct { + Name string `json:"-"` + Provider string + + Username string `json:"username"` + Password string `json:"password"` + + M3U string `json:"-"` + EPG string `json:"-"` + + VideoOnDemand bool `json:"-"` +} + +func (i *Configuration) GetProvider() (Provider, error) { + switch strings.ToLower(i.Provider) { + case "vaders": + log.Infoln("Source is vaders!") + return newVaders(i) + case "custom": + default: + log.Infoln("source is either custom or unknown, assuming custom!") + } + return nil, nil +} + +// ProviderChannel describes a channel available in the providers lineup with necessary pieces parsed into fields. +type ProviderChannel struct { + Name string + InternalID int // Should be the integer just before .ts. + Number *int + Logo string + StreamURL string + HD bool + Quality string + OnDemand bool + StreamFormat string +} + +// Provider describes a IPTV provider configuration. +type Provider interface { + Name() string + PlaylistURL() string + EPGURL() string + + // These are functions to extract information from playlists. + ParseLine(line m3u.Track) (*ProviderChannel, error) + + AuthenticatedStreamURL(channel *ProviderChannel) string + + MatchPlaylistKey() string +} + +// UnmarshalProviders takes V, a slice of Configuration and transforms it into a slice of Provider. +func UnmarshalProviders(v interface{}) ([]Provider, error) { + providers := make([]Provider, 0) + + uncasted, ok := v.([]interface{}) + if !ok { + panic(fmt.Errorf("provided slice is not of type []Configuration, it is of type %T", v)) + } + + for _, uncastedProvider := range uncasted { + ipProvider := uncastedProvider.(Configuration) + log.Infof("ipProvider %+v", ipProvider) + } + + return providers, nil +} + +// func testProvider() { +// v, vErr := NewVadersTV("hunter1", "hunter2", false) +// if vErr != nil { +// log.WithError(vErr).Errorf("Error setting up %s", v.Name()) +// } +// log.Infoln("Provider name is", v.Name()) +// log.Infoln("Playlist URL is", v.PlaylistURL()) +// log.Infoln("EPG URL is", v.EPGURL()) +// log.Infof("Stream URL is %+v", v.AuthenticatedStreamURL(&ProviderChannel{ +// Name: "Test channel", +// InternalID: 2862, +// })) + +// return +// } diff --git a/providers/tnt.go b/providers/tnt.go new file mode 100644 index 0000000..2f8295f --- /dev/null +++ b/providers/tnt.go @@ -0,0 +1,4 @@ +package providers + +// M3U: http://thesepeanutz.xyz:2052/get.php?username=xxx&password=xxx&type=m3u_plus&output=ts +// XMLTV: http://thesepeanutz.xyz:2052/xmltv.php?username=xxx&password=xxx diff --git a/providers/vaders.go b/providers/vaders.go new file mode 100644 index 0000000..a324c39 --- /dev/null +++ b/providers/vaders.go @@ -0,0 +1,72 @@ +package providers + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/tombowditch/telly/m3u" +) + +// M3U: http://api.vaders.tv/vget?username=xxx&password=xxx&format=ts +// XMLTV: http://vaders.tv/p2.xml + +type vader struct { + provider Configuration + + Token string `json:"-"` +} + +func newVaders(config *Configuration) (Provider, error) { + tok, tokErr := json.Marshal(config) + if tokErr != nil { + return nil, tokErr + } + + return &vader{*config, base64.StdEncoding.EncodeToString(tok)}, nil +} + +func (v *vader) Name() string { + return "Vaders.tv" +} + +func (v *vader) PlaylistURL() string { + return fmt.Sprintf("http://api.vaders.tv/vget?username=%s&password=%s&vod=%t&format=ts", v.provider.Username, v.provider.Password, v.provider.VideoOnDemand) +} + +func (v *vader) EPGURL() string { + return "http://vaders.tv/p2.xml" +} + +func (v *vader) ParseLine(line m3u.Track) (*ProviderChannel, error) { + streamURL := channelNumberExtractor(line.URI, -1)[0] + channelID, channelIDErr := strconv.Atoi(streamURL[1]) + if channelIDErr != nil { + return nil, channelIDErr + } + + // http://vapi.vaders.tv/play/dvr/${start}/TSID.ts?duration=3600&token= + // http://vapi.vaders.tv/play/TSID.ts?token= + // http://vapi.vaders.tv/play/vod/VODID.mp4.m3u8?token= + // http://vapi.vaders.tv/play/vod/VODID.avi.m3u8?token= + // http://vapi.vaders.tv/play/vod/VODID.mkv.m3u8?token= + + return &ProviderChannel{ + Name: line.Tags["tvg-name"], + Logo: line.Tags["tvg-logo"], + StreamURL: line.URI, + InternalID: channelID, + HD: strings.Contains(strings.ToLower(line.Tags["tvg-name"]), "hd"), + StreamFormat: streamURL[2], + }, nil +} + +func (v *vader) AuthenticatedStreamURL(channel *ProviderChannel) string { + return fmt.Sprintf("http://vapi.vaders.tv/play/%d.ts?token=%s", channel.InternalID, v.Token) +} + +func (v *vader) MatchPlaylistKey() string { + return "tvg-id" +} diff --git a/routes.go b/routes.go index 0350426..26a2012 100644 --- a/routes.go +++ b/routes.go @@ -10,11 +10,12 @@ import ( "github.com/gin-gonic/gin" ssdp "github.com/koron/go-ssdp" "github.com/sirupsen/logrus" + "github.com/spf13/viper" ginprometheus "github.com/zsais/go-gin-prometheus" ) -func serve(opts config) { - discoveryData := opts.DiscoveryData() +func serve(lineup *Lineup) { + discoveryData := GetDiscoveryData() log.Debugln("creating device xml") upnp := discoveryData.UPNP() @@ -26,7 +27,7 @@ func serve(opts config) { router := gin.New() router.Use(gin.Recovery()) - if opts.LogRequests { + if viper.GetBool("log.logrequests") { router.Use(ginrus()) } @@ -42,7 +43,7 @@ func serve(opts config) { Source: "Cable", SourceList: []string{"Cable"}, } - if opts.lineup.Refreshing { + if lineup.Refreshing { payload = LineupStatus{ ScanInProgress: convertibleBoolean(true), // Gotta fake out Plex. @@ -56,7 +57,7 @@ func serve(opts config) { router.POST("/lineup.post", func(c *gin.Context) { scanAction := c.Query("scan") if scanAction == "start" { - if refreshErr := opts.lineup.Refresh(); refreshErr != nil { + if refreshErr := lineup.Refresh(); refreshErr != nil { c.AbortWithError(http.StatusInternalServerError, refreshErr) } c.AbortWithStatus(http.StatusOK) @@ -68,22 +69,21 @@ func serve(opts config) { c.String(http.StatusBadRequest, "%s is not a valid scan command", scanAction) }) router.GET("/device.xml", deviceXML(upnp)) - router.GET("/lineup.json", lineup(opts.lineup)) - router.GET("/auto/:channelID", stream(opts.lineup)) - router.GET("/epg.xml", xmlTV(opts.lineup)) + router.GET("/lineup.json", serveLineup(lineup)) + router.GET("/auto/:channelID", stream(lineup)) + router.GET("/epg.xml", xmlTV(lineup)) router.GET("/debug.json", func(c *gin.Context) { - c.JSON(http.StatusOK, opts.lineup) + c.JSON(http.StatusOK, lineup) }) - if opts.SSDP { - log.Debugln("advertising telly service on network via UPNP/SSDP") - if _, ssdpErr := setupSSDP(opts.BaseAddress.String(), opts.FriendlyName, opts.DeviceUUID); ssdpErr != nil { + if viper.GetBool("discovery.ssdp") { + if _, ssdpErr := setupSSDP(viper.GetString("web.base-address"), viper.GetString("discovery.device-friendly-name"), viper.GetString("discovery.device-uuid")); ssdpErr != nil { log.WithError(ssdpErr).Errorln("telly cannot advertise over ssdp") } } - log.Infof("Listening and serving HTTP on %s", opts.ListenAddress) - if err := router.Run(opts.ListenAddress.String()); err != nil { + log.Infof("Listening and serving HTTP on %s", viper.GetString("web.listen-address")) + if err := router.Run(viper.GetString("web.listen-address")); err != nil { log.WithError(err).Panicln("Error starting up web server") } } @@ -106,7 +106,7 @@ func lineupStatus(status LineupStatus) gin.HandlerFunc { } } -func lineup(lineup *Lineup) gin.HandlerFunc { +func serveLineup(lineup *Lineup) gin.HandlerFunc { return func(c *gin.Context) { allChannels := make([]HDHomeRunChannel, 0) for _, playlist := range lineup.Playlists { diff --git a/structs.go b/structs.go index 23c014c..40d169b 100644 --- a/structs.go +++ b/structs.go @@ -6,52 +6,49 @@ import ( "fmt" "net" "regexp" - "strconv" ) type config struct { - RegexInclusive bool - Regex *regexp.Regexp + Filter struct { + RegexInclusive bool `toml:"Filter.RegexInclusive"` + Regex *regexp.Regexp `toml:"-"` + RegexStr string `toml:"Filter.Regex"` + } - Playlists []string - ConcurrentStreams int - StartingChannel int - XMLTVChannelNumbers bool + IPTV struct { + Playlists []string `toml:"IPTV.Playlists"` + ConcurrentStreams int `toml:"IPTV.ConcurrentStreams"` + StartingChannel int `toml:"IPTV.StartingChannel"` + XMLTVChannelNumbers bool `toml:"IPTV.XMLTVChannelNumbers"` + } - DeviceAuth string - DeviceID int - DeviceUUID string - FriendlyName string - Manufacturer string - ModelNumber string - FirmwareName string - FirmwareVersion string - SSDP bool + Discovery struct { + DeviceAuth string `toml:"Discovery.DeviceAuth"` + DeviceID int `toml:"Discovery.DeviceID"` + DeviceUUID string `toml:"Discovery.DeviceUUID"` + FriendlyName string `toml:"Discovery.FriendlyName"` + Manufacturer string `toml:"Discovery.Manufacturer"` + ModelNumber string `toml:"Discovery.ModelNumber"` + FirmwareName string `toml:"Discovery.FirmwareName"` + FirmwareVersion string `toml:"Discovery.FirmwareVersion"` + SSDP bool `toml:"Discovery.SSDP"` + } - LogRequests bool - LogLevel string + Log struct { + LogRequests bool `toml:"Log.Requests"` + Level string `toml:"Log.Level"` + } - ListenAddress *net.TCPAddr - BaseAddress *net.TCPAddr + Web struct { + ListenAddress *net.TCPAddr `toml:"-"` + BaseAddress *net.TCPAddr `toml:"-"` + ListenAddressStr string `toml:"Web.ListenAddress"` + BaseAddressStr string `toml:"Web.BaseAddress"` + } lineup *Lineup } -func (c *config) DiscoveryData() DiscoveryData { - return DiscoveryData{ - FriendlyName: c.FriendlyName, - Manufacturer: c.Manufacturer, - ModelNumber: c.ModelNumber, - FirmwareName: c.FirmwareName, - TunerCount: c.ConcurrentStreams, - FirmwareVersion: c.FirmwareVersion, - DeviceID: strconv.Itoa(c.DeviceID), - DeviceAuth: c.DeviceAuth, - BaseURL: fmt.Sprintf("http://%s", c.BaseAddress), - LineupURL: fmt.Sprintf("http://%s/lineup.json", c.BaseAddress), - } -} - // DiscoveryData contains data about telly to expose in the HDHomeRun format for Plex detection. type DiscoveryData struct { FriendlyName string diff --git a/utils.go b/utils.go new file mode 100644 index 0000000..a106b96 --- /dev/null +++ b/utils.go @@ -0,0 +1,34 @@ +package main + +import ( + "fmt" + "net" + "regexp" + "strconv" + + "github.com/spf13/viper" +) + +func GetTCPAddr(key string) *net.TCPAddr { + addr, _ := net.ResolveTCPAddr("tcp", viper.GetString(key)) + return addr +} + +func GetStringAsRegex(key string) *regexp.Regexp { + return regexp.MustCompile(viper.GetString(key)) +} + +func GetDiscoveryData() DiscoveryData { + return DiscoveryData{ + FriendlyName: viper.GetString("discovery.device-friendly-name"), + Manufacturer: viper.GetString("discovery.device-manufacturer"), + ModelNumber: viper.GetString("discovery.device-model-number"), + FirmwareName: viper.GetString("discovery.device-firmware-name"), + TunerCount: viper.GetInt("iptv.concurrent-streams"), + FirmwareVersion: viper.GetString("discovery.device-firmware-version"), + DeviceID: strconv.Itoa(viper.GetInt("discovery.device-id")), + DeviceAuth: viper.GetString("discovery.device-auth"), + BaseURL: fmt.Sprintf("http://%s", viper.GetString("web.base-address")), + LineupURL: fmt.Sprintf("http://%s/lineup.json", viper.GetString("web.base-address")), + } +} From 35ba398bcebf18c3e2ba831a8695e20fdf9f2256 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Wed, 15 Aug 2018 17:03:40 -0700 Subject: [PATCH 007/114] Checkpoint again. Too much to list, all good things --- .gitignore | 1 + Gopkg.lock | 129 ++-- Gopkg.toml | 8 - internal/go-gin-prometheus/middleware.go | 402 ++++++++++++ {m3u => internal/m3uplus}/main.go | 7 +- internal/providers/custom.go | 89 +++ {providers => internal/providers}/eternal.go | 4 +- .../providers}/hellraiser.go | 2 +- internal/providers/iptv-epg.go | 92 +++ internal/providers/iris.go | 4 + internal/providers/main.go | 97 +++ {providers => internal/providers}/tnt.go | 2 +- internal/providers/vaders.go | 133 ++++ {xmltv => internal/xmltv}/xmltv.dtd | 0 {xmltv => internal/xmltv}/xmltv.go | 8 +- {xmltv => internal/xmltv}/xmltv_test.go | 4 +- lineup.go | 609 ++++++++---------- main.go | 112 +++- providers/iptv-epg.go | 4 - providers/main.go | 97 --- providers/vaders.go | 72 --- routes.go | 89 ++- structs.go | 69 +- utils.go | 16 +- 24 files changed, 1350 insertions(+), 700 deletions(-) create mode 100644 internal/go-gin-prometheus/middleware.go rename {m3u => internal/m3uplus}/main.go (96%) create mode 100644 internal/providers/custom.go rename {providers => internal/providers}/eternal.go (64%) rename {providers => internal/providers}/hellraiser.go (79%) create mode 100644 internal/providers/iptv-epg.go create mode 100644 internal/providers/iris.go create mode 100644 internal/providers/main.go rename {providers => internal/providers}/tnt.go (83%) create mode 100644 internal/providers/vaders.go rename {xmltv => internal/xmltv}/xmltv.dtd (100%) rename {xmltv => internal/xmltv}/xmltv.go (99%) rename {xmltv => internal/xmltv}/xmltv_test.go (94%) delete mode 100644 providers/iptv-epg.go delete mode 100644 providers/main.go delete mode 100644 providers/vaders.go diff --git a/.gitignore b/.gitignore index a22990c..05fc00e 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ vendor/ /.release /.tarballs *.tar.gz +telly.config.* diff --git a/Gopkg.lock b/Gopkg.lock index cc87f39..3209f48 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -1,25 +1,6 @@ # This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. -[[projects]] - branch = "master" - digest = "1:315c5f2f60c76d89b871c73f9bd5fe689cad96597afd50fb9992228ef80bdd34" - name = "github.com/alecthomas/template" - packages = [ - ".", - "parse", - ] - pruneopts = "UT" - revision = "a0175ee3bccc567396460bf5acd36800cb10c49c" - -[[projects]] - branch = "master" - digest = "1:c198fdc381e898e8fb62b8eb62758195091c313ad18e52a3067366e1dda2fb3c" - name = "github.com/alecthomas/units" - packages = ["."] - pruneopts = "UT" - revision = "2efee857e7cfd4f3d0138cc3cbb1b4966962b93a" - [[projects]] branch = "master" digest = "1:d6afaeed1502aa28e80a4ed0981d570ad91b2579193404256ce672ed0a609e0d" @@ -28,6 +9,14 @@ pruneopts = "UT" revision = "3a771d992973f24aa725d07868b467d1ddfceafb" +[[projects]] + digest = "1:abeb38ade3f32a92943e5be54f55ed6d6e3b6602761d74b4aab4c9dd45c18abd" + name = "github.com/fsnotify/fsnotify" + packages = ["."] + pruneopts = "UT" + revision = "c2828203cd70a50dcccfb2761f8b1f8ceef9a8e9" + version = "v1.4.7" + [[projects]] branch = "master" digest = "1:36fe9527deed01d2a317617e59304eb2c4ce9f8a24115bcc5c2e37b3aee5bae4" @@ -56,6 +45,25 @@ revision = "b4deda0973fb4c70b50d226b1af49f3da59f5265" version = "v1.1.0" +[[projects]] + branch = "master" + digest = "1:a361611b8c8c75a1091f00027767f7779b29cb37c456a71b8f2604c88057ab40" + name = "github.com/hashicorp/hcl" + packages = [ + ".", + "hcl/ast", + "hcl/parser", + "hcl/printer", + "hcl/scanner", + "hcl/strconv", + "hcl/token", + "json/parser", + "json/scanner", + "json/token", + ] + pruneopts = "UT" + revision = "ef8a98b0bbce4a65b5aa4c368430a80ddc533168" + [[projects]] branch = "master" digest = "1:8f57afa9ef1d9205094e9d89b9cb4ecb3123f342c4eb0053d7631181b511e6e4" @@ -80,6 +88,14 @@ revision = "e2ffdb16a802fe2bb95e2e35ff34f0e53aeef34f" version = "v0.1.0" +[[projects]] + digest = "1:c568d7727aa262c32bdf8a3f7db83614f7af0ed661474b24588de635c20024c7" + name = "github.com/magiconair/properties" + packages = ["."] + pruneopts = "UT" + revision = "c2353362d570a7bfa228149c62842019201cfb71" + version = "v1.8.0" + [[projects]] digest = "1:fa610f9fe6a93f4a75e64c83673dfff9bf1a34bbb21e6102021b6bc7850834a3" name = "github.com/mattn/go-isatty" @@ -103,6 +119,14 @@ pruneopts = "UT" revision = "f15292f7a699fcc1a38a80977f80a046874ba8ac" +[[projects]] + digest = "1:95741de3af260a92cc5c7f3f3061e85273f5a81b5db20d4bd68da74bd521675e" + name = "github.com/pelletier/go-toml" + packages = ["."] + pruneopts = "UT" + revision = "c01d1270ff3e442a8a57cddc1c92dc1138598194" + version = "v1.2.0" + [[projects]] digest = "1:d14a5f4bfecf017cb780bdde1b6483e5deb87e12c332544d2c430eda58734bcb" name = "github.com/prometheus/client_golang" @@ -157,19 +181,54 @@ version = "v1.0.6" [[projects]] - digest = "1:c268acaa4a4d94a467980e5e91452eb61c460145765293dc0aed48e5e9919cc6" - name = "github.com/ugorji/go" - packages = ["codec"] + digest = "1:bd1ae00087d17c5a748660b8e89e1043e1e5479d0fea743352cda2f8dd8c4f84" + name = "github.com/spf13/afero" + packages = [ + ".", + "mem", + ] pruneopts = "UT" - revision = "c88ee250d0221a57af388746f5cf03768c21d6e2" + revision = "787d034dfe70e44075ccc060d346146ef53270ad" + version = "v1.1.1" + +[[projects]] + digest = "1:516e71bed754268937f57d4ecb190e01958452336fa73dbac880894164e91c1f" + name = "github.com/spf13/cast" + packages = ["."] + pruneopts = "UT" + revision = "8965335b8c7107321228e3e3702cab9832751bac" + version = "v1.2.0" [[projects]] branch = "master" - digest = "1:7e4543a28ce437be9d263089699c5fd6cefc0f02a63592f7f85c0c4e21245e0a" - name = "github.com/zsais/go-gin-prometheus" + digest = "1:8a020f916b23ff574845789daee6818daf8d25a4852419aae3f0b12378ba432a" + name = "github.com/spf13/jwalterweatherman" + packages = ["."] + pruneopts = "UT" + revision = "14d3d4c518341bea657dd8a226f5121c0ff8c9f2" + +[[projects]] + digest = "1:dab83a1bbc7ad3d7a6ba1a1cc1760f25ac38cdf7d96a5cdd55cd915a4f5ceaf9" + name = "github.com/spf13/pflag" packages = ["."] pruneopts = "UT" - revision = "3f93884fa240fd102425d65ce9781e561ba40496" + revision = "9a97c102cda95a86cec2345a6f09f55a939babf5" + version = "v1.0.2" + +[[projects]] + digest = "1:4fc8a61287ccfb4286e1ca5ad2ce3b0b301d746053bf44ac38cf34e40ae10372" + name = "github.com/spf13/viper" + packages = ["."] + pruneopts = "UT" + revision = "907c19d40d9a6c9bb55f040ff4ae45271a4754b9" + version = "v1.1.0" + +[[projects]] + digest = "1:c268acaa4a4d94a467980e5e91452eb61c460145765293dc0aed48e5e9919cc6" + name = "github.com/ugorji/go" + packages = ["codec"] + pruneopts = "UT" + revision = "c88ee250d0221a57af388746f5cf03768c21d6e2" [[projects]] branch = "master" @@ -207,7 +266,7 @@ revision = "98c5dad5d1a0e8a73845ecc8897d0bd56586511d" [[projects]] - digest = "1:aa4d6967a3237f8367b6bf91503964a77183ecf696f1273e8ad3551bb4412b5f" + digest = "1:4392fcf42d5cf0e3ff78c96b2acf8223d49e4fdc53eb77c99d2f8dfe4680e006" name = "golang.org/x/text" packages = [ "encoding", @@ -222,24 +281,19 @@ "encoding/unicode", "internal/gen", "internal/tag", + "internal/triegen", + "internal/ucd", "internal/utf8internal", "language", "runes", "transform", "unicode/cldr", + "unicode/norm", ] pruneopts = "UT" revision = "f21a4dfb5e38f5895301dc265a8def02365cc3d0" version = "v0.3.0" -[[projects]] - digest = "1:c06d9e11d955af78ac3bbb26bd02e01d2f61f689e1a3bce2ef6fb683ef8a7f2d" - name = "gopkg.in/alecthomas/kingpin.v2" - packages = ["."] - pruneopts = "UT" - revision = "947dcec5ba9c011838740e680966fd7087a71d0d" - version = "v2.2.6" - [[projects]] digest = "1:1b4724d3c8125f6044925f02b485b74bfec9905cbf579d95aafd1a6c8f8447d3" name = "gopkg.in/go-playground/validator.v8" @@ -264,11 +318,12 @@ "github.com/kr/pretty", "github.com/mitchellh/mapstructure", "github.com/prometheus/client_golang/prometheus", + "github.com/prometheus/client_golang/prometheus/promhttp", "github.com/prometheus/common/version", "github.com/sirupsen/logrus", - "github.com/zsais/go-gin-prometheus", + "github.com/spf13/pflag", + "github.com/spf13/viper", "golang.org/x/net/html/charset", - "gopkg.in/alecthomas/kingpin.v2", ] solver-name = "gps-cdcl" solver-version = 1 diff --git a/Gopkg.toml b/Gopkg.toml index 31ebde3..546090b 100644 --- a/Gopkg.toml +++ b/Gopkg.toml @@ -49,14 +49,6 @@ name = "github.com/sirupsen/logrus" version = "1.0.6" -[[constraint]] - branch = "master" - name = "github.com/zsais/go-gin-prometheus" - -[[constraint]] - name = "gopkg.in/alecthomas/kingpin.v2" - version = "2.2.6" - [prune] go-tests = true unused-packages = true diff --git a/internal/go-gin-prometheus/middleware.go b/internal/go-gin-prometheus/middleware.go new file mode 100644 index 0000000..f3d7477 --- /dev/null +++ b/internal/go-gin-prometheus/middleware.go @@ -0,0 +1,402 @@ +// Package ginprometheus provides a Logrus logger for Gin requests. Slightly modified to remove spammy logs. +// For more info see https://github.com/zsais/go-gin-prometheus/pull/22. +package ginprometheus + +import ( + "bytes" + "io/ioutil" + "net/http" + "os" + "strconv" + "time" + + "github.com/gin-gonic/gin" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" + log "github.com/sirupsen/logrus" +) + +var defaultMetricPath = "/metrics" + +// Standard default metrics +// counter, counter_vec, gauge, gauge_vec, +// histogram, histogram_vec, summary, summary_vec +var reqCnt = &Metric{ + ID: "reqCnt", + Name: "requests_total", + Description: "How many HTTP requests processed, partitioned by status code and HTTP method.", + Type: "counter_vec", + Args: []string{"code", "method", "handler", "host", "url"}} + +var reqDur = &Metric{ + ID: "reqDur", + Name: "request_duration_seconds", + Description: "The HTTP request latencies in seconds.", + Type: "summary"} + +var resSz = &Metric{ + ID: "resSz", + Name: "response_size_bytes", + Description: "The HTTP response sizes in bytes.", + Type: "summary"} + +var reqSz = &Metric{ + ID: "reqSz", + Name: "request_size_bytes", + Description: "The HTTP request sizes in bytes.", + Type: "summary"} + +var standardMetrics = []*Metric{ + reqCnt, + reqDur, + resSz, + reqSz, +} + +/* +RequestCounterURLLabelMappingFn is a function which can be supplied to the middleware to control +the cardinality of the request counter's "url" label, which might be required in some contexts. +For instance, if for a "/customer/:name" route you don't want to generate a time series for every +possible customer name, you could use this function: +func(c *gin.Context) string { + url := c.Request.URL.String() + for _, p := range c.Params { + if p.Key == "name" { + url = strings.Replace(url, p.Value, ":name", 1) + break + } + } + return url +} +which would map "/customer/alice" and "/customer/bob" to their template "/customer/:name". +*/ +type RequestCounterURLLabelMappingFn func(c *gin.Context) string + +// Metric is a definition for the name, description, type, ID, and +// prometheus.Collector type (i.e. CounterVec, Summary, etc) of each metric +type Metric struct { + MetricCollector prometheus.Collector + ID string + Name string + Description string + Type string + Args []string +} + +// Prometheus contains the metrics gathered by the instance and its path +type Prometheus struct { + reqCnt *prometheus.CounterVec + reqDur, reqSz, resSz prometheus.Summary + router *gin.Engine + listenAddress string + Ppg PrometheusPushGateway + + MetricsList []*Metric + MetricsPath string + + ReqCntURLLabelMappingFn RequestCounterURLLabelMappingFn +} + +// PrometheusPushGateway contains the configuration for pushing to a Prometheus pushgateway (optional) +type PrometheusPushGateway struct { + + // Push interval in seconds + PushIntervalSeconds time.Duration + + // Push Gateway URL in format http://domain:port + // where JOBNAME can be any string of your choice + PushGatewayURL string + + // Local metrics URL where metrics are fetched from, this could be ommited in the future + // if implemented using prometheus common/expfmt instead + MetricsURL string + + // pushgateway job name, defaults to "gin" + Job string +} + +// NewPrometheus generates a new set of metrics with a certain subsystem name +func NewPrometheus(subsystem string, customMetricsList ...[]*Metric) *Prometheus { + + var metricsList []*Metric + + if len(customMetricsList) > 1 { + panic("Too many args. NewPrometheus( string, ).") + } else if len(customMetricsList) == 1 { + metricsList = customMetricsList[0] + } + + for _, metric := range standardMetrics { + metricsList = append(metricsList, metric) + } + + p := &Prometheus{ + MetricsList: metricsList, + MetricsPath: defaultMetricPath, + ReqCntURLLabelMappingFn: func(c *gin.Context) string { + return c.Request.URL.String() // i.e. by default do nothing, i.e. return URL as is + }, + } + + p.registerMetrics(subsystem) + + return p +} + +// SetPushGateway sends metrics to a remote pushgateway exposed on pushGatewayURL +// every pushIntervalSeconds. Metrics are fetched from metricsURL +func (p *Prometheus) SetPushGateway(pushGatewayURL, metricsURL string, pushIntervalSeconds time.Duration) { + p.Ppg.PushGatewayURL = pushGatewayURL + p.Ppg.MetricsURL = metricsURL + p.Ppg.PushIntervalSeconds = pushIntervalSeconds + p.startPushTicker() +} + +// SetPushGatewayJob job name, defaults to "gin" +func (p *Prometheus) SetPushGatewayJob(j string) { + p.Ppg.Job = j +} + +// SetListenAddress for exposing metrics on address. If not set, it will be exposed at the +// same address of the gin engine that is being used +func (p *Prometheus) SetListenAddress(address string) { + p.listenAddress = address + if p.listenAddress != "" { + p.router = gin.Default() + } +} + +// SetListenAddressWithRouter for using a separate router to expose metrics. (this keeps things like GET /metrics out of +// your content's access log). +func (p *Prometheus) SetListenAddressWithRouter(listenAddress string, r *gin.Engine) { + p.listenAddress = listenAddress + if len(p.listenAddress) > 0 { + p.router = r + } +} + +func (p *Prometheus) setMetricsPath(e *gin.Engine) { + + if p.listenAddress != "" { + p.router.GET(p.MetricsPath, prometheusHandler()) + p.runServer() + } else { + e.GET(p.MetricsPath, prometheusHandler()) + } +} + +func (p *Prometheus) setMetricsPathWithAuth(e *gin.Engine, accounts gin.Accounts) { + + if p.listenAddress != "" { + p.router.GET(p.MetricsPath, gin.BasicAuth(accounts), prometheusHandler()) + p.runServer() + } else { + e.GET(p.MetricsPath, gin.BasicAuth(accounts), prometheusHandler()) + } + +} + +func (p *Prometheus) runServer() { + if p.listenAddress != "" { + go p.router.Run(p.listenAddress) + } +} + +func (p *Prometheus) getMetrics() []byte { + response, _ := http.Get(p.Ppg.MetricsURL) + + defer response.Body.Close() + body, _ := ioutil.ReadAll(response.Body) + + return body +} + +func (p *Prometheus) getPushGatewayURL() string { + h, _ := os.Hostname() + if p.Ppg.Job == "" { + p.Ppg.Job = "gin" + } + return p.Ppg.PushGatewayURL + "/metrics/job/" + p.Ppg.Job + "/instance/" + h +} + +func (p *Prometheus) sendMetricsToPushGateway(metrics []byte) { + req, err := http.NewRequest("POST", p.getPushGatewayURL(), bytes.NewBuffer(metrics)) + client := &http.Client{} + if _, err = client.Do(req); err != nil { + log.WithError(err).Errorln("Error sending to push gateway") + } +} + +func (p *Prometheus) startPushTicker() { + ticker := time.NewTicker(time.Second * p.Ppg.PushIntervalSeconds) + go func() { + for range ticker.C { + p.sendMetricsToPushGateway(p.getMetrics()) + } + }() +} + +// NewMetric associates prometheus.Collector based on Metric.Type +func NewMetric(m *Metric, subsystem string) prometheus.Collector { + var metric prometheus.Collector + switch m.Type { + case "counter_vec": + metric = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Subsystem: subsystem, + Name: m.Name, + Help: m.Description, + }, + m.Args, + ) + case "counter": + metric = prometheus.NewCounter( + prometheus.CounterOpts{ + Subsystem: subsystem, + Name: m.Name, + Help: m.Description, + }, + ) + case "gauge_vec": + metric = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Subsystem: subsystem, + Name: m.Name, + Help: m.Description, + }, + m.Args, + ) + case "gauge": + metric = prometheus.NewGauge( + prometheus.GaugeOpts{ + Subsystem: subsystem, + Name: m.Name, + Help: m.Description, + }, + ) + case "histogram_vec": + metric = prometheus.NewHistogramVec( + prometheus.HistogramOpts{ + Subsystem: subsystem, + Name: m.Name, + Help: m.Description, + }, + m.Args, + ) + case "histogram": + metric = prometheus.NewHistogram( + prometheus.HistogramOpts{ + Subsystem: subsystem, + Name: m.Name, + Help: m.Description, + }, + ) + case "summary_vec": + metric = prometheus.NewSummaryVec( + prometheus.SummaryOpts{ + Subsystem: subsystem, + Name: m.Name, + Help: m.Description, + }, + m.Args, + ) + case "summary": + metric = prometheus.NewSummary( + prometheus.SummaryOpts{ + Subsystem: subsystem, + Name: m.Name, + Help: m.Description, + }, + ) + } + return metric +} + +func (p *Prometheus) registerMetrics(subsystem string) { + + for _, metricDef := range p.MetricsList { + metric := NewMetric(metricDef, subsystem) + if err := prometheus.Register(metric); err != nil { + log.WithError(err).Errorf("%s could not be registered in Prometheus", metricDef.Name) + } + switch metricDef { + case reqCnt: + p.reqCnt = metric.(*prometheus.CounterVec) + case reqDur: + p.reqDur = metric.(prometheus.Summary) + case resSz: + p.resSz = metric.(prometheus.Summary) + case reqSz: + p.reqSz = metric.(prometheus.Summary) + } + metricDef.MetricCollector = metric + } +} + +// Use adds the middleware to a gin engine. +func (p *Prometheus) Use(e *gin.Engine) { + e.Use(p.handlerFunc()) + p.setMetricsPath(e) +} + +// UseWithAuth adds the middleware to a gin engine with BasicAuth. +func (p *Prometheus) UseWithAuth(e *gin.Engine, accounts gin.Accounts) { + e.Use(p.handlerFunc()) + p.setMetricsPathWithAuth(e, accounts) +} + +func (p *Prometheus) handlerFunc() gin.HandlerFunc { + return func(c *gin.Context) { + if c.Request.URL.String() == p.MetricsPath { + c.Next() + return + } + + start := time.Now() + reqSz := computeApproximateRequestSize(c.Request) + + c.Next() + + status := strconv.Itoa(c.Writer.Status()) + elapsed := float64(time.Since(start)) / float64(time.Second) + resSz := float64(c.Writer.Size()) + + p.reqDur.Observe(elapsed) + url := p.ReqCntURLLabelMappingFn(c) + p.reqCnt.WithLabelValues(status, c.Request.Method, c.HandlerName(), c.Request.Host, url).Inc() + p.reqSz.Observe(float64(reqSz)) + p.resSz.Observe(resSz) + } +} + +func prometheusHandler() gin.HandlerFunc { + h := promhttp.Handler() + return func(c *gin.Context) { + h.ServeHTTP(c.Writer, c.Request) + } +} + +// From https://github.com/DanielHeckrath/gin-prometheus/blob/master/gin_prometheus.go +func computeApproximateRequestSize(r *http.Request) int { + s := 0 + if r.URL != nil { + s = len(r.URL.String()) + } + + s += len(r.Method) + s += len(r.Proto) + for name, values := range r.Header { + s += len(name) + for _, value := range values { + s += len(value) + } + } + s += len(r.Host) + + // N.B. r.Form and r.MultipartForm are assumed to be included in r.URL. + + if r.ContentLength != -1 { + s += int(r.ContentLength) + } + return s +} diff --git a/m3u/main.go b/internal/m3uplus/main.go similarity index 96% rename from m3u/main.go rename to internal/m3uplus/main.go index 5aa449f..43d2606 100644 --- a/m3u/main.go +++ b/internal/m3uplus/main.go @@ -1,4 +1,5 @@ -package m3u +// Package m3uplus provides a M3U Plus parser. +package m3uplus import ( "bytes" @@ -13,7 +14,7 @@ import ( // Playlist is a type that represents an m3u playlist containing 0 or more tracks type Playlist struct { - Tracks []*Track + Tracks []Track } // Track represents an m3u track @@ -86,7 +87,7 @@ func decodeLine(playlist *Playlist, line string, lineNumber int) error { switch { case strings.HasPrefix(line, "#EXTINF:"): - track := &Track{ + track := Track{ Raw: line, LineNumber: lineNumber, } diff --git a/internal/providers/custom.go b/internal/providers/custom.go new file mode 100644 index 0000000..e6a93c0 --- /dev/null +++ b/internal/providers/custom.go @@ -0,0 +1,89 @@ +package providers + +import ( + "strconv" + "strings" + + m3u "github.com/tombowditch/telly/internal/m3uplus" + "github.com/tombowditch/telly/internal/xmltv" +) + +type customProvider struct { + BaseConfig Configuration +} + +func newCustomProvider(config *Configuration) (Provider, error) { + return &customProvider{*config}, nil +} + +func (i *customProvider) Name() string { + return i.BaseConfig.Name +} + +func (i *customProvider) PlaylistURL() string { + return i.BaseConfig.M3U +} + +func (i *customProvider) EPGURL() string { + return i.BaseConfig.EPG +} + +// ParseTrack matches the provided M3U track an XMLTV channel and returns a ProviderChannel. +func (i *customProvider) ParseTrack(track m3u.Track, channelMap map[string]xmltv.Channel) (*ProviderChannel, error) { + channelVal := track.Tags["tvg-chno"] + if i.BaseConfig.ChannelNumberKey != "" { + channelVal = track.Tags[i.BaseConfig.ChannelNumberKey] + } + + chanNum := 0 + + if channelNumber, channelNumberErr := strconv.Atoi(channelVal); channelNumberErr == nil { + chanNum = channelNumber + } + + nameVal := track.Name + if i.BaseConfig.NameKey != "" { + nameVal = track.Tags[i.BaseConfig.NameKey] + } + + logoVal := track.Tags["tvg-logo"] + if i.BaseConfig.LogoKey != "" { + logoVal = track.Tags[i.BaseConfig.LogoKey] + } + + pChannel := &ProviderChannel{ + Name: nameVal, + Logo: logoVal, + Number: chanNum, + StreamURL: track.URI, + StreamID: chanNum, + HD: strings.Contains(strings.ToLower(track.Name), "hd"), + StreamFormat: "Unknown", + Track: track, + OnDemand: false, + } + + epgVal := track.Tags["tvg-id"] + if i.BaseConfig.EPGMatchKey != "" { + epgVal = track.Tags[i.BaseConfig.EPGMatchKey] + } + + if xmlChan, ok := channelMap[epgVal]; ok { + pChannel.EPGMatch = epgVal + pChannel.EPGChannel = &xmlChan + } + + return pChannel, nil +} + +func (i *customProvider) ProcessProgramme(programme xmltv.Programme) *xmltv.Programme { + return &programme +} + +func (i *customProvider) Configuration() Configuration { + return i.BaseConfig +} + +func (i *customProvider) RegexKey() string { + return i.BaseConfig.FilterKey +} diff --git a/providers/eternal.go b/internal/providers/eternal.go similarity index 64% rename from providers/eternal.go rename to internal/providers/eternal.go index fc59a4f..72bc1b6 100644 --- a/providers/eternal.go +++ b/internal/providers/eternal.go @@ -1,4 +1,4 @@ package providers -// M3U:http://live.eternaltv.net:25461/get.php?username=xxxxxxx&password=xxxxxx&output=ts&type=m3u_plus -// XMLTV: http://live.eternaltv.net:25461/xmltv.php?username=xxxxx&password=xxxxx&type=m3u_plus&output=ts +// M3U:http://live.eternaltv.net:25461/get.php?username=xxxxxxx&password=xxxxxx&output=ts&type=m3uplus +// XMLTV: http://live.eternaltv.net:25461/xmltv.php?username=xxxxx&password=xxxxx&type=m3uplus&output=ts diff --git a/providers/hellraiser.go b/internal/providers/hellraiser.go similarity index 79% rename from providers/hellraiser.go rename to internal/providers/hellraiser.go index de264a8..0608474 100644 --- a/providers/hellraiser.go +++ b/internal/providers/hellraiser.go @@ -1,4 +1,4 @@ package providers -// Playlist URL: http://liquidit.info:8080/get.php?username=xxxx&password=xxxxxxx&type=m3u_plus&output=ts +// Playlist URL: http://liquidit.info:8080/get.php?username=xxxx&password=xxxxxxx&type=m3uplus&output=ts // XMLTV URL: http://liquidit.info:8080/xmltv.php?username=xxxxxx&password=xxxxxx diff --git a/internal/providers/iptv-epg.go b/internal/providers/iptv-epg.go new file mode 100644 index 0000000..63c5602 --- /dev/null +++ b/internal/providers/iptv-epg.go @@ -0,0 +1,92 @@ +package providers + +import ( + "fmt" + "strconv" + "strings" + + m3u "github.com/tombowditch/telly/internal/m3uplus" + "github.com/tombowditch/telly/internal/xmltv" +) + +// M3U: http://iptv-epg.com/.m3u +// XMLTV: http://iptv-epg.com/.xml + +type iptvepg struct { + BaseConfig Configuration +} + +func newIPTVEPG(config *Configuration) (Provider, error) { + return &iptvepg{*config}, nil +} + +func (i *iptvepg) Name() string { + return "IPTV-EPG" +} + +func (i *iptvepg) PlaylistURL() string { + return fmt.Sprintf("http://iptv-epg.com/%s.m3u", i.BaseConfig.Username) +} + +func (i *iptvepg) EPGURL() string { + return fmt.Sprintf("http://iptv-epg.com/%s.xml", i.BaseConfig.Password) +} + +// ParseTrack matches the provided M3U track an XMLTV channel and returns a ProviderChannel. +func (i *iptvepg) ParseTrack(track m3u.Track, channelMap map[string]xmltv.Channel) (*ProviderChannel, error) { + channelVal := track.Tags["tvg-chno"] + if i.BaseConfig.ChannelNumberKey != "" { + channelVal = track.Tags[i.BaseConfig.ChannelNumberKey] + } + + channelNumber, channelNumberErr := strconv.Atoi(channelVal) + if channelNumberErr != nil { + return nil, channelNumberErr + } + + nameVal := track.Name + if i.BaseConfig.NameKey != "" { + nameVal = track.Tags[i.BaseConfig.NameKey] + } + + logoVal := track.Tags["tvg-logo"] + if i.BaseConfig.LogoKey != "" { + logoVal = track.Tags[i.BaseConfig.LogoKey] + } + + pChannel := &ProviderChannel{ + Name: nameVal, + Logo: logoVal, + Number: channelNumber, + StreamURL: track.URI, + StreamID: channelNumber, + HD: strings.Contains(strings.ToLower(track.Name), "hd"), + StreamFormat: "Unknown", + Track: track, + OnDemand: false, + } + + epgVal := track.Tags["tvg-id"] + if i.BaseConfig.EPGMatchKey != "" { + epgVal = track.Tags[i.BaseConfig.EPGMatchKey] + } + + if xmlChan, ok := channelMap[epgVal]; ok { + pChannel.EPGMatch = epgVal + pChannel.EPGChannel = &xmlChan + } + + return pChannel, nil +} + +func (i *iptvepg) ProcessProgramme(programme xmltv.Programme) *xmltv.Programme { + return &programme +} + +func (i *iptvepg) Configuration() Configuration { + return i.BaseConfig +} + +func (i *iptvepg) RegexKey() string { + return "group-title" +} diff --git a/internal/providers/iris.go b/internal/providers/iris.go new file mode 100644 index 0000000..01d10ec --- /dev/null +++ b/internal/providers/iris.go @@ -0,0 +1,4 @@ +package providers + +// http://irislinks.net:83/get.php?username=username&password=password&type=m3uplus&output=ts +// http://irislinks.net:83/xmltv.php?username=username&password=password diff --git a/internal/providers/main.go b/internal/providers/main.go new file mode 100644 index 0000000..f772532 --- /dev/null +++ b/internal/providers/main.go @@ -0,0 +1,97 @@ +package providers + +import ( + "regexp" + "strings" + + m3u "github.com/tombowditch/telly/internal/m3uplus" + "github.com/tombowditch/telly/internal/xmltv" +) + +var streamNumberRegex = regexp.MustCompile(`/(\d+).(ts|.*.m3u8)`).FindAllStringSubmatch +var channelNumberRegex = regexp.MustCompile(`^[0-9]+[[:space:]]?$`).MatchString +var callSignRegex = regexp.MustCompile(`^[A-Z0-9]+$`).MatchString +var hdRegex = regexp.MustCompile(`hd|4k`) + +type Configuration struct { + Name string `json:"-"` + Provider string + + Username string `json:"username"` + Password string `json:"password"` + + M3U string `json:"-"` + EPG string `json:"-"` + + VideoOnDemand bool `json:"-"` + + Filter string + FilterKey string + FilterRaw bool + + SortKey string + SortReverse bool + + Favorites []string + FavoriteTag string + + CacheFiles bool + + NameKey string + LogoKey string + ChannelNumberKey string + EPGMatchKey string +} + +func (i *Configuration) GetProvider() (Provider, error) { + switch strings.ToLower(i.Provider) { + case "vaders": + return newVaders(i) + case "iptv-epg", "iptvepg": + return newIPTVEPG(i) + default: + return newCustomProvider(i) + } +} + +// ProviderChannel describes a channel available in the providers lineup with necessary pieces parsed into fields. +type ProviderChannel struct { + Name string + StreamID int // Should be the integer just before .ts. + Number int + Logo string + StreamURL string + HD bool + Quality string + OnDemand bool + StreamFormat string + Favorite bool + + EPGMatch string + EPGChannel *xmltv.Channel + EPGProgrammes []xmltv.Programme + Track m3u.Track +} + +// Provider describes a IPTV provider configuration. +type Provider interface { + Name() string + PlaylistURL() string + EPGURL() string + + // These are functions to extract information from playlists. + ParseTrack(track m3u.Track, channelMap map[string]xmltv.Channel) (*ProviderChannel, error) + ProcessProgramme(programme xmltv.Programme) *xmltv.Programme + + RegexKey() string + Configuration() Configuration +} + +func contains(s []string, e string) bool { + for _, ss := range s { + if e == ss { + return true + } + } + return false +} diff --git a/providers/tnt.go b/internal/providers/tnt.go similarity index 83% rename from providers/tnt.go rename to internal/providers/tnt.go index 2f8295f..3960706 100644 --- a/providers/tnt.go +++ b/internal/providers/tnt.go @@ -1,4 +1,4 @@ package providers -// M3U: http://thesepeanutz.xyz:2052/get.php?username=xxx&password=xxx&type=m3u_plus&output=ts +// M3U: http://thesepeanutz.xyz:2052/get.php?username=xxx&password=xxx&type=m3uplus&output=ts // XMLTV: http://thesepeanutz.xyz:2052/xmltv.php?username=xxx&password=xxx diff --git a/internal/providers/vaders.go b/internal/providers/vaders.go new file mode 100644 index 0000000..bcb2fa6 --- /dev/null +++ b/internal/providers/vaders.go @@ -0,0 +1,133 @@ +package providers + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "regexp" + "strconv" + "strings" + + log "github.com/sirupsen/logrus" + m3u "github.com/tombowditch/telly/internal/m3uplus" + "github.com/tombowditch/telly/internal/xmltv" +) + +// This regex matches and extracts the following URLs. +// http://vapi.vaders.tv/play/dvr/${start}/123.ts?duration=3600&token= +// http://vapi.vaders.tv/play/123.ts?token= +// http://vapi.vaders.tv/play/vod/123.mp4.m3u8?token= +// http://vapi.vaders.tv/play/vod/123.avi.m3u8?token= +// http://vapi.vaders.tv/play/vod/123.mkv.m3u8?token= +var vadersURL = regexp.MustCompile(`/(vod/|dvr/\${start}/)?(\d+).(ts|.*.m3u8)\?(duration=\d+&)?token=`).FindAllStringSubmatch + +// M3U: http://api.vaders.tv/vget?username=xxx&password=xxx&format=ts +// XMLTV: http://vaders.tv/p2.xml + +type vader struct { + BaseConfig Configuration + + Token string `json:"-"` +} + +func newVaders(config *Configuration) (Provider, error) { + tok, tokErr := json.Marshal(config) + if tokErr != nil { + return nil, tokErr + } + + return &vader{*config, base64.StdEncoding.EncodeToString(tok)}, nil +} + +func (v *vader) Name() string { + return "Vaders.tv" +} + +func (v *vader) PlaylistURL() string { + return fmt.Sprintf("http://api.vaders.tv/vget?username=%s&password=%s&vod=%t&format=ts", v.BaseConfig.Username, v.BaseConfig.Password, v.BaseConfig.VideoOnDemand) +} + +func (v *vader) EPGURL() string { + return "http://vaders.tv/p2.xml.gz" +} + +// ParseTrack matches the provided M3U track an XMLTV channel and returns a ProviderChannel. +func (v *vader) ParseTrack(track m3u.Track, channelMap map[string]xmltv.Channel) (*ProviderChannel, error) { + streamURL := vadersURL(track.URI, -1)[0] + + vod := strings.Contains(streamURL[1], "vod") + + if v.BaseConfig.VideoOnDemand == false && vod { + return nil, nil + } + + channelID, channelIDErr := strconv.Atoi(streamURL[2]) + if channelIDErr != nil { + return nil, channelIDErr + } + + nameVal := track.Tags["tvg-name"] + if v.BaseConfig.NameKey != "" { + nameVal = track.Tags[v.BaseConfig.NameKey] + } + + logoVal := track.Tags["tvg-logo"] + if v.BaseConfig.LogoKey != "" { + logoVal = track.Tags[v.BaseConfig.LogoKey] + } + + pChannel := &ProviderChannel{ + Name: nameVal, + Logo: logoVal, + StreamURL: track.URI, + StreamID: channelID, + HD: strings.Contains(strings.ToLower(track.Tags["tvg-name"]), "hd"), + StreamFormat: streamURL[3], + Track: track, + OnDemand: vod, + } + + if xmlChan, ok := channelMap[track.Tags["tvg-id"]]; ok { + pChannel.EPGMatch = track.Tags["tvg-id"] + pChannel.EPGChannel = &xmlChan + + for _, displayName := range xmlChan.DisplayNames { + if channelNumberRegex(displayName.Value) { + if chanNum, chanNumErr := strconv.Atoi(displayName.Value); chanNumErr == nil { + pChannel.Number = chanNum + } + } + } + } + + favoriteTag := "tvg-id" + + if v.BaseConfig.FavoriteTag != "" { + favoriteTag = v.BaseConfig.FavoriteTag + } + + if _, ok := track.Tags[favoriteTag]; !ok { + log.Panicf("The specified favorite tag (%s) doesn't exist on the track with URL %s", favoriteTag, track.URI) + return nil, nil + } + + pChannel.Favorite = contains(v.BaseConfig.Favorites, track.Tags[favoriteTag]) + + return pChannel, nil +} + +func (v *vader) ProcessProgramme(programme xmltv.Programme) *xmltv.Programme { + for idx, title := range programme.Titles { + programme.Titles[idx].Value = strings.Replace(title.Value, " [New!]", "", -1) + } + + return &programme +} + +func (v *vader) Configuration() Configuration { + return v.BaseConfig +} + +func (v *vader) RegexKey() string { + return "group-title" +} diff --git a/xmltv/xmltv.dtd b/internal/xmltv/xmltv.dtd similarity index 100% rename from xmltv/xmltv.dtd rename to internal/xmltv/xmltv.dtd diff --git a/xmltv/xmltv.go b/internal/xmltv/xmltv.go similarity index 99% rename from xmltv/xmltv.go rename to internal/xmltv/xmltv.go index 911c275..5567f1f 100644 --- a/xmltv/xmltv.go +++ b/internal/xmltv/xmltv.go @@ -65,6 +65,10 @@ type Channel struct { Icons []Icon `xml:"icon,omitempty" json:"icons,omitempty"` URLs []string `xml:"url,omitempty" json:"urls,omitempty" ` ID string `xml:"id,attr" json:"id,omitempty" ` + + // These fields are outside of the XMLTV spec. + // LCN is the local channel number. Plex will show it in place of the channel ID if it exists. + LCN int `xml:"lcn" json:"lcn,omitempty"` } // Programme details of a single programme transmission @@ -102,10 +106,6 @@ type Programme struct { Videoplus string `xml:"videoplus,attr,omitempty" json:"videoplus,omitempty"` Channel string `xml:"channel,attr" json:"channel"` Clumpidx string `xml:"clumpidx,attr,omitempty" json:"clumpidx,omitempty"` - - // These fields are outside of the XMLTV spec. - // LCN is the local channel number. Plex will show it in place of the channel ID if it exists. - LCN int `xml:"lcn,attr" json:"lcn,omitempty"` } // CommonElement element structure that is common, i.e. Italy diff --git a/xmltv/xmltv_test.go b/internal/xmltv/xmltv_test.go similarity index 94% rename from xmltv/xmltv_test.go rename to internal/xmltv/xmltv_test.go index 0cdc479..f2eec7c 100644 --- a/xmltv/xmltv_test.go +++ b/internal/xmltv/xmltv_test.go @@ -17,7 +17,7 @@ func dummyReader(charset string, input io.Reader) (io.Reader, error) { } func TestDecode(t *testing.T) { - // Example downloaded from http://wiki.xmltv.org/index.php/XMLTVFormat + // Example downloaded from http://wiki.xmltv.org/index.php/internal/xmltvFormat // One may check it with `xmllint --noout --dtdvalid xmltv.dtd example.xml` f, err := os.Open("example.xml") if err != nil { @@ -60,7 +60,7 @@ func TestDecode(t *testing.T) { }, Icons: []Icon{ Icon{ - Source: `file://C:\Perl\site/share/xmltv/icons/KERA.gif`, + Source: `file://C:\Perl\site/share/internal/xmltv/icons/KERA.gif`, }, }, } diff --git a/lineup.go b/lineup.go index 4b3badb..3881f7c 100644 --- a/lineup.go +++ b/lineup.go @@ -1,6 +1,7 @@ package main import ( + "compress/gzip" "encoding/xml" "fmt" "io" @@ -8,360 +9,303 @@ import ( "os" "regexp" "sort" - "strconv" "strings" - "time" "github.com/spf13/viper" - "github.com/tombowditch/telly/m3u" - "github.com/tombowditch/telly/providers" - "github.com/tombowditch/telly/xmltv" + m3u "github.com/tombowditch/telly/internal/m3uplus" + "github.com/tombowditch/telly/internal/providers" + "github.com/tombowditch/telly/internal/xmltv" ) -var channelNumberRegex = regexp.MustCompile(`^[0-9]+[[:space:]]?$`).MatchString -var callSignRegex = regexp.MustCompile(`^[A-Z0-9]+$`).MatchString -var hdRegex = regexp.MustCompile(`hd|4k`) - -// Track describes a single M3U segment. This struct includes m3u.Track as well as specific IPTV fields we want to get. -type Track struct { - *m3u.Track - SafeURI string `json:"URI"` - Catchup string `m3u:"catchup" json:",omitempty"` - CatchupDays string `m3u:"catchup-days" json:",omitempty"` - CatchupSource string `m3u:"catchup-source" json:",omitempty"` - GroupTitle string `m3u:"group-title" json:",omitempty"` - TvgID string `m3u:"tvg-id" json:",omitempty"` - TvgLogo string `m3u:"tvg-logo" json:",omitempty"` - TvgName string `m3u:"tvg-name" json:",omitempty"` - TvgChannelNumber string `m3u:"tvg-chno" json:",omitempty"` - ChannelID string `m3u:"channel-id" json:",omitempty"` - - XMLTVChannel *xmlTVChannel `json:",omitempty"` - XMLTVProgrammes *[]xmltv.Programme `json:",omitempty"` +// var channelNumberRegex = regexp.MustCompile(`^[0-9]+[[:space:]]?$`).MatchString +// var callSignRegex = regexp.MustCompile(`^[A-Z0-9]+$`).MatchString +// var hdRegex = regexp.MustCompile(`hd|4k`) + +// hdHomeRunLineupItem is a HDHomeRun specification compatible representation of a Track available in the lineup. +type hdHomeRunLineupItem struct { + XMLName xml.Name `xml:"Program" json:"-"` + + AudioCodec string `xml:",omitempty" json:",omitempty"` + DRM convertibleBoolean `xml:",omitempty" json:",string,omitempty"` + Favorite convertibleBoolean `xml:",omitempty" json:",string,omitempty"` + GuideName string `xml:",omitempty" json:",omitempty"` + GuideNumber int `xml:",omitempty" json:",string,omitempty"` + HD convertibleBoolean `xml:",omitempty" json:",string,omitempty"` + URL string `xml:",omitempty" json:",omitempty"` + VideoCodec string `xml:",omitempty" json:",omitempty"` + + provider providers.Provider + providerChannel providers.ProviderChannel } -func (t *Track) PrettyName() string { - if t.XMLTVChannel != nil { - return t.XMLTVChannel.LongName - } else if t.TvgName != "" { - return t.TvgName - } else if t.Track.Name != "" { - return t.Track.Name +func newHDHRItem(provider *providers.Provider, providerChannel *providers.ProviderChannel) hdHomeRunLineupItem { + return hdHomeRunLineupItem{ + DRM: convertibleBoolean(false), + GuideName: providerChannel.Name, + GuideNumber: providerChannel.Number, + Favorite: convertibleBoolean(providerChannel.Favorite), + HD: convertibleBoolean(providerChannel.HD), + URL: fmt.Sprintf("http://%s/auto/v%d", viper.GetString("web.base-address"), providerChannel.Number), + provider: *provider, + providerChannel: *providerChannel, } - - return t.Name -} - -// Playlist describes a single M3U playlist. -type Playlist struct { - *m3u.Playlist - *M3UFile - - Tracks []Track - Channels []HDHomeRunChannel - TracksCount int - FilteredTracksCount int - EPGProvided bool -} - -// Filter will filter the raw m3u.Playlist m3u.Track slice into the Track slice of the Playlist. -func (p *Playlist) Filter() error { - for _, oldTrack := range p.Playlist.Tracks { - track := Track{ - Track: oldTrack, - SafeURI: safeStringsRegex.ReplaceAllStringFunc(oldTrack.URI, stringSafer), - } - - if unmarshalErr := oldTrack.UnmarshalTags(&track); unmarshalErr != nil { - return unmarshalErr - } - - if GetStringAsRegex("filter.regexstr").MatchString(track.Raw) == viper.GetBool("filter.regexinclusive") { - p.Tracks = append(p.Tracks, track) - } - } - - return nil -} - -// M3UFile describes a path and transport to a M3U provided in the configuration. -type M3UFile struct { - Path string `json:"-"` - SafePath string `json:"Path"` - Transport string } -// HDHomeRunChannel is a HDHomeRun specification compatible representation of a Track available in the Lineup. -type HDHomeRunChannel struct { - AudioCodec string `json:",omitempty"` - DRM convertibleBoolean `json:",string,omitempty"` - Favorite convertibleBoolean `json:",string,omitempty"` - GuideName string `json:",omitempty"` - GuideNumber int `json:",string,omitempty"` - HD convertibleBoolean `json:",string,omitempty"` - URL string `json:",omitempty"` - VideoCodec string `json:",omitempty"` - - track *Track -} - -// Lineup is a collection of tracks -type Lineup struct { - Providers []providers.Provider - - Playlists []Playlist - PlaylistsCount int - TracksCount int - FilteredTracksCount int - - StartingChannelNumber int - channelNumber int +// lineup contains the state of the application. +type lineup struct { + Sources []providers.Provider - Refreshing bool - LastRefreshed time.Time `json:",omitempty"` + Scanning bool - xmlTvChannelMap map[string]xmlTVChannel - channelsInXMLTv []string - xmlTv xmltv.TV - xmlTvSourceInfoURL []string - xmlTvSourceInfoName []string - xmlTvSourceDataURL []string + // Stores the channel number for found channels without a number. + assignedChannelNumber int + // If true, use channel numbers found in EPG, if any, before assigning. xmlTVChannelNumbers bool - chanNumToURLMap map[string]string + channels map[int]hdHomeRunLineupItem } -// NewLineup returns a new Lineup for the given config struct. -func NewLineup() *Lineup { - tv := xmltv.TV{ - GeneratorInfoName: namespaceWithVersion, - GeneratorInfoURL: "https://github.com/tombowditch/telly", - } - - lineup := &Lineup{ - xmlTVChannelNumbers: viper.GetBool("iptv.xmltv-channels"), - chanNumToURLMap: make(map[string]string), - xmlTv: tv, - xmlTvChannelMap: make(map[string]xmlTVChannel), - StartingChannelNumber: viper.GetInt("iptv.starting-channel"), - channelNumber: viper.GetInt("iptv.starting-channel"), - Refreshing: false, - LastRefreshed: time.Now(), - } - +// newLineup returns a new lineup for the given config struct. +func newLineup() *lineup { var cfgs []providers.Configuration if unmarshalErr := viper.UnmarshalKey("source", &cfgs); unmarshalErr != nil { log.WithError(unmarshalErr).Panicln("Unable to unmarshal source configuration to slice of providers.Configuration, check your configuration!") } + if viper.IsSet("iptv.playlist") { + log.Warnln("Legacy --iptv.playlist argument or environment variable provided, using Custom provider with default configuration, this may fail! If so, you should use a configuration file for full flexibility.") + regexStr := ".*" + if viper.IsSet("filter.regex") { + regexStr = viper.GetString("filter.regex") + } + cfgs = append(cfgs, providers.Configuration{ + Name: "Legacy provider created using arguments/environment variables", + M3U: viper.GetString("iptv.playlist"), + Provider: "custom", + Filter: regexStr, + FilterRaw: true, + }) + } + + lineup := &lineup{ + assignedChannelNumber: viper.GetInt("iptv.starting-channel"), + xmlTVChannelNumbers: viper.GetBool("iptv.xmltv-channels"), + channels: make(map[int]hdHomeRunLineupItem), + } + for _, cfg := range cfgs { - log.Infoln("Adding provider", cfg.Name) provider, providerErr := cfg.GetProvider() if providerErr != nil { panic(providerErr) } - if addErr := lineup.AddProvider(provider); addErr != nil { - log.WithError(addErr).Panicln("error adding new provider to lineup") - } + + lineup.Sources = append(lineup.Sources, provider) } return lineup } -// AddProvider adds a new Provider to the Lineup. -func (l *Lineup) AddProvider(provider providers.Provider) error { - reader, info, readErr := l.getM3U(provider.PlaylistURL()) - if readErr != nil { - log.WithError(readErr).Errorln("error getting m3u") - return readErr - } - - rawPlaylist, err := m3u.Decode(reader) - if err != nil { - log.WithError(err).Errorln("unable to parse m3u file") - return err - } +// Scan processes all sources. +func (l *lineup) Scan() error { - if provider.EPGURL() != "" { - epg, epgReadErr := l.getXMLTV(provider.EPGURL()) - if epgReadErr != nil { - log.WithError(epgReadErr).Errorln("error getting XMLTV") - return epgReadErr - } + l.Scanning = true - chanMap, chanMapErr := l.processXMLTV(epg) - if chanMapErr != nil { - log.WithError(chanMapErr).Errorln("Error building channel mapping") - } + totalAddedChannels := 0 - for chanID, chann := range chanMap { - l.xmlTvChannelMap[chanID] = chann + for _, provider := range l.Sources { + addedChannels, providerErr := l.processProvider(provider) + if providerErr != nil { + log.WithError(providerErr).Errorln("error when processing provider") } + totalAddedChannels = totalAddedChannels + addedChannels } - playlist, playlistErr := l.NewPlaylist(provider, rawPlaylist, info) - if playlistErr != nil { - return playlistErr + if totalAddedChannels > 420 { + log.Panicf("telly has loaded more than 420 channels (%d) into the lineup. Plex does not deal well with more than this amount and will more than likely hang when trying to fetch channels. You must use regular expressions to filter out channels. You can also start another Telly instance.", totalAddedChannels) } - l.Playlists = append(l.Playlists, playlist) - l.PlaylistsCount = len(l.Playlists) - l.TracksCount = l.TracksCount + playlist.TracksCount - l.FilteredTracksCount = l.FilteredTracksCount + playlist.FilteredTracksCount + l.Scanning = false return nil } -// NewPlaylist will return a new and filtered Playlist for the given m3u.Playlist and M3UFile. -func (l *Lineup) NewPlaylist(provider providers.Provider, rawPlaylist *m3u.Playlist, info *M3UFile) (Playlist, error) { - hasEPG := provider.EPGURL() != "" - playlist := Playlist{rawPlaylist, info, nil, nil, len(rawPlaylist.Tracks), 0, hasEPG} +func (l *lineup) processProvider(provider providers.Provider) (int, error) { + addedChannels := 0 + m3u, channelMap, programmeMap, prepareErr := l.prepareProvider(provider) + if prepareErr != nil { + log.WithError(prepareErr).Errorln("error when preparing provider") + } - if filterErr := playlist.Filter(); filterErr != nil { - log.WithError(filterErr).Errorln("error during filtering of channels, check your regex and try again") - return playlist, filterErr + if provider.Configuration().SortKey != "" { + sortKey := provider.Configuration().SortKey + sort.Slice(m3u.Tracks, func(i, j int) bool { + if _, ok := m3u.Tracks[i].Tags[sortKey]; ok { + log.Panicf("the provided sort key (%s) doesn't exist in the M3U!", sortKey) + return false + } + ii := m3u.Tracks[i].Tags[sortKey] + jj := m3u.Tracks[j].Tags[sortKey] + if provider.Configuration().SortReverse { + return ii < jj + } + return ii > jj + }) } - for idx, track := range playlist.Tracks { - tt, channelNumber, hd, ttErr := l.processTrack(provider, track) - if ttErr != nil { - return playlist, ttErr + for _, track := range m3u.Tracks { + // First, we run the filter. + if !l.FilterTrack(provider, track) { + log.Debugf("Channel %s didn't pass the provider (%s) filter, skipping!", track.Name, provider.Name()) + return addedChannels, nil } - if hasEPG && tt.XMLTVChannel == nil { - log.Warnf("%s (#%d) is not being exposed to Plex because there was no EPG data found.", tt.Name, channelNumber) - continue + // Then we do the provider specific translation to a hdHomeRunLineupItem. + channel, channelErr := provider.ParseTrack(track, channelMap) + if channelErr != nil { + return addedChannels, channelErr } - playlist.Tracks[idx] = *tt - - guideName := tt.PrettyName() + channel, processErr := l.processProviderChannel(channel, programmeMap) + if processErr != nil { + log.WithError(processErr).Errorln("error processing track") + } else if channel == nil { + log.Infof("Channel %s was returned empty from the provider (%s)", track.Name, provider.Name()) + continue + } + addedChannels = addedChannels + 1 - log.Debugln("Assigning", channelNumber, l.channelNumber, "to", guideName) + l.channels[channel.Number] = newHDHRItem(&provider, channel) + } - hdhr := HDHomeRunChannel{ - GuideNumber: channelNumber, - GuideName: guideName, - URL: fmt.Sprintf("http://%s/auto/v%d", viper.GetString("web.base-address"), channelNumber), - HD: convertibleBoolean(hd), - DRM: convertibleBoolean(false), - } + log.Infof("Loaded %d channels into the lineup from %s", addedChannels, provider.Name()) - if !channelExists(playlist.Channels, hdhr) { - playlist.Channels = append(playlist.Channels, hdhr) - l.chanNumToURLMap[strconv.Itoa(channelNumber)] = tt.Track.URI - } + return addedChannels, nil +} - if channelNumber == l.channelNumber { // Only increment lineup channel number if its for a channel that didnt have a XMLTV entry. - l.channelNumber = l.channelNumber + 1 - } +func (l *lineup) prepareProvider(provider providers.Provider) (*m3u.Playlist, map[string]xmltv.Channel, map[string][]xmltv.Programme, error) { + cacheFiles := provider.Configuration().CacheFiles + reader, m3uErr := getM3U(provider.PlaylistURL(), cacheFiles) + if m3uErr != nil { + log.WithError(m3uErr).Errorln("unable to get m3u file") + return nil, nil, nil, m3uErr } - sort.Slice(l.xmlTv.Channels, func(i, j int) bool { - first, _ := strconv.Atoi(l.xmlTv.Channels[i].ID) - second, _ := strconv.Atoi(l.xmlTv.Channels[j].ID) - return first < second - }) + rawPlaylist, err := m3u.Decode(reader) + if err != nil { + log.WithError(err).Errorln("unable to parse m3u file") + return nil, nil, nil, err + } - playlist.FilteredTracksCount = len(playlist.Tracks) - exposedChannels.Add(float64(playlist.FilteredTracksCount)) - log.Debugf("Added %d channels to the lineup", playlist.FilteredTracksCount) + channelMap, programmeMap, epgErr := l.prepareEPG(provider, cacheFiles) + if epgErr != nil { + log.WithError(epgErr).Errorln("error when parsing EPG") + return nil, nil, nil, epgErr + } - return playlist, nil + return rawPlaylist, channelMap, programmeMap, nil } -func (l Lineup) processTrack(provider providers.Provider, track Track) (*Track, int, bool, error) { +func (l *lineup) processProviderChannel(channel *providers.ProviderChannel, programmeMap map[string][]xmltv.Programme) (*providers.ProviderChannel, error) { + if channel.EPGChannel != nil { + channel.EPGProgrammes = programmeMap[channel.EPGMatch] + } - hd := hdRegex.MatchString(strings.ToLower(track.Track.Raw)) - channelNumber := l.channelNumber + if !l.xmlTVChannelNumbers || channel.Number == 0 { + channel.Number = l.assignedChannelNumber + l.assignedChannelNumber = l.assignedChannelNumber + 1 + } - if xmlChan, ok := l.xmlTvChannelMap[track.TvgID]; ok { - log.Debugln("found an entry in xmlTvChannelMap for", track.Name) - if l.xmlTVChannelNumbers && xmlChan.Number != 0 { - channelNumber = xmlChan.Number - } else { - xmlChan.Number = channelNumber - } - l.channelsInXMLTv = append(l.channelsInXMLTv, track.TvgID) - track.XMLTVChannel = &xmlChan - l.xmlTv.Channels = append(l.xmlTv.Channels, xmlChan.RemappedChannel(track)) - if xmlChan.Programmes != nil { - track.XMLTVProgrammes = &xmlChan.Programmes - for _, programme := range xmlChan.Programmes { - newProgramme := programme - for idx, title := range programme.Titles { - programme.Titles[idx].Value = strings.Replace(title.Value, " [New!]", "", -1) // Hardcoded fix for Vaders - } - newProgramme.Channel = strconv.Itoa(channelNumber) - if hd { - if newProgramme.Video == nil { - newProgramme.Video = &xmltv.Video{} - } - newProgramme.Video.Quality = "HDTV" - } - l.xmlTv.Programmes = append(l.xmlTv.Programmes, newProgramme) - } - } + if channel.EPGChannel != nil && channel.EPGChannel.LCN == 0 { + channel.EPGChannel.LCN = channel.Number } - return &track, channelNumber, hd, nil + if channel.Logo != "" && channel.EPGChannel != nil && !containsIcon(channel.EPGChannel.Icons, channel.Logo) { + channel.EPGChannel.Icons = append(channel.EPGChannel.Icons, xmltv.Icon{Source: channel.Logo}) + } + + return channel, nil } -// Refresh will rescan all playlists for any channel changes. -func (l Lineup) Refresh() error { +func (l *lineup) FilterTrack(provider providers.Provider, track m3u.Track) bool { + config := provider.Configuration() + if config.Filter == "" { + return true + } - if l.Refreshing { - log.Warnln("A refresh is already underway yet, another one was requested") - return nil + filterRegex, regexErr := regexp.Compile(config.Filter) + if regexErr != nil { + log.WithError(regexErr).Panicln("your regex is invalid") + return false } - log.Warnln("Refreshing the lineup!") + if config.FilterRaw { + return filterRegex.MatchString(track.Raw) + } - l.Refreshing = true + log.Debugf("track.Tags %+v", track.Tags) - existingPlaylists := make([]Playlist, len(l.Playlists)) - copy(existingPlaylists, l.Playlists) + filterKey := provider.RegexKey() + if config.FilterKey != "" { + if key, ok := track.Tags[config.FilterKey]; key != "" && ok { + filterKey = config.FilterKey + } else { + log.Panicf("the provided filter key (%s) does not exist or is blank", config.FilterKey) + } + } - l.Playlists = nil - l.TracksCount = 0 - l.FilteredTracksCount = 0 - l.StartingChannelNumber = 0 + if _, ok := track.Tags[filterKey]; !ok { + log.Panicf("Provided filter key %s doesn't exist in M3U tags", filterKey) + } - // FIXME: Re-implement AddProvider to use a provider. - // for _, playlist := range existingPlaylists { - // if addErr := l.AddProvider(playlist.M3UFile.Path); addErr != nil { - // return addErr - // } - // } + log.Debugf("Checking if filter (%s) matches string %s", config.Filter, track.Tags[filterKey]) - log.Infoln("Done refreshing the lineup!") + return filterRegex.MatchString(track.Tags[filterKey]) - l.LastRefreshed = time.Now() - l.Refreshing = false +} - return nil +func (l *lineup) prepareEPG(provider providers.Provider, cacheFiles bool) (map[string]xmltv.Channel, map[string][]xmltv.Programme, error) { + var epg *xmltv.TV + epgChannelMap := make(map[string]xmltv.Channel) + epgProgrammeMap := make(map[string][]xmltv.Programme) + if provider.EPGURL() != "" { + var epgErr error + epg, epgErr = getXMLTV(provider.EPGURL(), cacheFiles) + if epgErr != nil { + return epgChannelMap, epgProgrammeMap, epgErr + } + + for _, channel := range epg.Channels { + epgChannelMap[channel.ID] = channel + + for _, programme := range epg.Programmes { + if programme.Channel == channel.ID { + epgProgrammeMap[channel.ID] = append(epgProgrammeMap[channel.ID], *provider.ProcessProgramme(programme)) + } + } + } + } + + return epgChannelMap, epgProgrammeMap, nil } -func (l *Lineup) getM3U(path string) (io.Reader, *M3UFile, error) { +func getM3U(path string, cacheFiles bool) (io.Reader, error) { safePath := safeStringsRegex.ReplaceAllStringFunc(path, stringSafer) log.Infof("Loading M3U from %s", safePath) - file, transport, err := l.getFile(path) + file, _, err := getFile(path, cacheFiles) if err != nil { - return nil, nil, err + return nil, err } - return file, &M3UFile{ - Path: path, - SafePath: safePath, - Transport: transport, - }, nil + return file, nil } -func (l *Lineup) getXMLTV(path string) (*xmltv.TV, error) { - file, _, err := l.getFile(path) +func getXMLTV(path string, cacheFiles bool) (*xmltv.TV, error) { + safePath := safeStringsRegex.ReplaceAllStringFunc(path, stringSafer) + log.Infof("Loading XMLTV from %s", safePath) + file, _, err := getFile(path, cacheFiles) if err != nil { return nil, err } @@ -376,118 +320,69 @@ func (l *Lineup) getXMLTV(path string) (*xmltv.TV, error) { return tvSetup, nil } -func (l *Lineup) getFile(path string) (io.Reader, string, error) { - safePath := safeStringsRegex.ReplaceAllStringFunc(path, stringSafer) - log.Infof("Loading file from %s", safePath) - +func getFile(path string, cacheFiles bool) (io.Reader, string, error) { transport := "disk" if strings.HasPrefix(strings.ToLower(path), "http") { + resp, err := http.Get(path) if err != nil { return nil, transport, err } - //defer resp.Body.Close() - return resp.Body, transport, nil - } - - file, fileErr := os.Open(path) - if fileErr != nil { - return nil, transport, fileErr - } - - return file, transport, nil -} - -type xmlTVChannel struct { - ID string - Number int - CallSign string - ShortName string - LongName string + // defer func() { + // err := resp.Body.Close() + // if err != nil { + // log.WithError(err).Panicln("error when closing HTTP body reader") + // } + // }() + + if strings.HasSuffix(strings.ToLower(path), ".gz") { + log.Infof("File (%s) is gzipp'ed, ungzipping now, this might take a while", path) + gz, gzErr := gzip.NewReader(resp.Body) + if gzErr != nil { + return nil, transport, gzErr + } - NumberAssigned bool + defer func() { + err := gz.Close() + if err != nil { + log.WithError(err).Panicln("error when closing gzip reader") + } + }() - Programmes []xmltv.Programme + if cacheFiles { + return writeFile(path, transport, gz) + } - Original xmltv.Channel -} + return gz, transport, nil + } -func (x *xmlTVChannel) RemappedChannel(t Track) xmltv.Channel { - newX := x.Original - newX.ID = strconv.Itoa(x.Number) - if t.TvgLogo != "" { - newX.Icons = append(newX.Icons, xmltv.Icon{Source: t.TvgLogo}) - } - if t.Track.Name != "" { - newX.DisplayNames = append(newX.DisplayNames, xmltv.CommonElement{Value: t.Track.Name}) - } - return newX -} + if cacheFiles { + return writeFile(path, transport, resp.Body) + } -func (l *Lineup) processXMLTV(tv *xmltv.TV) (map[string]xmlTVChannel, error) { - programmeMap := make(map[string][]xmltv.Programme) - for _, programme := range tv.Programmes { - programmeMap[programme.Channel] = append(programmeMap[programme.Channel], programme) + return resp.Body, transport, nil } - channelMap := make(map[string]xmlTVChannel, 0) - for _, tvChann := range tv.Channels { - xTVChan := &xmlTVChannel{ - ID: tvChann.ID, - Original: tvChann, - } - if programmes, ok := programmeMap[tvChann.ID]; ok { - xTVChan.Programmes = programmes - } - if channelNumberRegex(tvChann.ID) { - xTVChan.Number, _ = strconv.Atoi(tvChann.ID) - } - displayNames := []string{} - for _, displayName := range tvChann.DisplayNames { - displayNames = append(displayNames, displayName.Value) - } - sort.StringSlice(displayNames).Sort() - for i := 0; i < 10; i++ { - extractDisplayNames(displayNames, xTVChan) - } - channelMap[xTVChan.ID] = *xTVChan - // Duplicate this to first display-name just in case the M3U and XMLTV differ significantly. - for _, dn := range tvChann.DisplayNames { - channelMap[dn.Value] = *xTVChan - } + file, fileErr := os.Open(path) + if fileErr != nil { + return nil, transport, fileErr } - return channelMap, nil + return file, transport, nil } -func extractDisplayNames(displayNames []string, xTVChan *xmlTVChannel) { - for _, displayName := range displayNames { - if channelNumberRegex(displayName) { - if chanNum, chanNumErr := strconv.Atoi(displayName); chanNumErr == nil { - log.Debugln(displayName, "is channel number!") - xTVChan.Number = chanNum - } - } else if !strings.HasPrefix(displayName, fmt.Sprintf("%d", xTVChan.Number)) { - if xTVChan.LongName == "" { - xTVChan.LongName = displayName - log.Debugln(displayName, "is long name!") - } else if !callSignRegex(displayName) && len(xTVChan.LongName) < len(displayName) { - xTVChan.ShortName = xTVChan.LongName - xTVChan.LongName = displayName - log.Debugln(displayName, "is NEW long name, replacing", xTVChan.ShortName) - } else if callSignRegex(displayName) { - xTVChan.CallSign = displayName - log.Debugln(displayName, "is call sign!") - } - } - } +func writeFile(path, transport string, reader io.Reader) (io.Reader, string, error) { + // buf := new(bytes.Buffer) + // buf.ReadFrom(reader) + // buf.Bytes() + return reader, transport, nil } -func channelExists(s []HDHomeRunChannel, e HDHomeRunChannel) bool { - for _, a := range s { - if a.GuideName == e.GuideName { +func containsIcon(s []xmltv.Icon, e string) bool { + for _, ss := range s { + if e == ss.Source { return true } } diff --git a/main.go b/main.go index 7746d66..66fb61f 100644 --- a/main.go +++ b/main.go @@ -27,7 +27,6 @@ var ( Hooks: make(logrus.LevelHooks), Level: logrus.DebugLevel, } - opts = config{} exposedChannels = prometheus.NewGauge( prometheus.GaugeOpts{ @@ -71,8 +70,8 @@ func main() { flag.String("filter.regex", ".*", "Use regex to filter for channels that you want. A basic example would be .*UK.*. $(TELLY_FILTER_REGEX)") // Web flags - flag.String("web.listen-address", "localhost:6077", "Address to listen on for web interface and telemetry $(TELLY_WEB_LISTEN_ADDRESS)") - flag.String("web.base-address", "localhost:6077", "The address to expose via discovery. Useful with reverse proxy $(TELLY_WEB_BASE_ADDRESS)") + flag.StringP("web.listen-address", "l", "localhost:6077", "Address to listen on for web interface and telemetry $(TELLY_WEB_LISTEN_ADDRESS)") + flag.StringP("web.base-address", "b", "localhost:6077", "The address to expose via discovery. Useful with reverse proxy $(TELLY_WEB_BASE_ADDRESS)") // Log flags flag.String("log.level", logrus.InfoLevel.String(), "Only log messages with the given severity or above. Valid levels: [debug, info, warn, error, fatal] $(TELLY_LOG_LEVEL)") @@ -84,25 +83,63 @@ func main() { flag.Int("iptv.starting-channel", 10000, "The channel number to start exposing from. $(TELLY_IPTV_STARTING_CHANNEL)") flag.Bool("iptv.xmltv-channels", true, "Use channel numbers discovered via XMLTV file, if provided. $(TELLY_IPTV_XMLTV_CHANNELS)") + // Misc flags + flag.StringP("config.file", "c", "", "Path to your config file. If not set, configuration is searched for in the current working directory, $HOME/.telly/ and /etc/telly/. If provided, it will override all other arguments and environment variables. $(TELLY_CONFIG_FILE)") + flag.Bool("version", false, "Show application version") + flag.CommandLine.AddGoFlagSet(fflag.CommandLine) + + deprecatedFlags := []string{ + "discovery.device-id", + "discovery.device-friendly-name", + "discovery.device-auth", + "discovery.device-manufacturer", + "discovery.device-model-number", + "discovery.device-firmware-name", + "discovery.device-firmware-version", + "discovery.ssdp", + "iptv.playlist", + "iptv.streams", + "iptv.starting-channel", + "iptv.xmltv-channels", + "filter.regex-inclusive", + "filter.regex", + } + + for _, depFlag := range deprecatedFlags { + if depErr := flag.CommandLine.MarkDeprecated(depFlag, "use the configuration file instead."); depErr != nil { + log.WithError(depErr).Panicf("error marking flag %s as deprecated", depFlag) + } + } + flag.Parse() - viper.BindPFlags(flag.CommandLine) - viper.SetConfigName("telly.config") // name of config file (without extension) - viper.AddConfigPath("/etc/telly/") // path to look for the config file in - viper.AddConfigPath("$HOME/.telly") // call multiple times to add many search paths - viper.AddConfigPath(".") // optionally look for config in the working directory - viper.SetEnvPrefix(namespace) - viper.AutomaticEnv() - err := viper.ReadInConfig() // Find and read the config file - if err != nil { // Handle errors reading the config file + if bindErr := viper.BindPFlags(flag.CommandLine); bindErr != nil { + log.WithError(bindErr).Panicln("error binding flags to viper") + } + + if flag.Lookup("version").Changed { + fmt.Println(version.Print(namespace)) + os.Exit(0) + } + + if flag.Lookup("config.file").Changed { + viper.SetConfigFile(flag.Lookup("config.file").Value.String()) + } else { + viper.SetConfigName("telly.config") + viper.AddConfigPath("/etc/telly/") + viper.AddConfigPath("$HOME/.telly") + viper.AddConfigPath(".") + viper.SetEnvPrefix(namespace) + viper.AutomaticEnv() + } + + err := viper.ReadInConfig() + if err != nil { if _, ok := err.(viper.ConfigFileNotFoundError); !ok { log.WithError(err).Panicln("fatal error while reading config file:") } } - log.Infoln("Starting telly", version.Info()) - log.Infoln("Build context", version.BuildContext()) - prometheus.MustRegister(version.NewCollector("telly"), exposedChannels) level, parseLevelErr := logrus.ParseLevel(viper.GetString("log.level")) @@ -111,11 +148,32 @@ func main() { } log.SetLevel(level) + log.Infoln("telly is preparing to go live", version.Info()) + log.Debugln("Build context", version.BuildContext()) + + validateConfig() + + viper.Set("discovery.device-friendly-name", fmt.Sprintf("HDHomerun (%s)", viper.GetString("discovery.device-friendly-name"))) + viper.Set("discovery.device-uuid", fmt.Sprintf("%d-AE2A-4E54-BBC9-33AF7D5D6A92", viper.GetInt("discovery.device-id"))) + if log.Level == logrus.DebugLevel { - js, _ := json.MarshalIndent(viper.AllSettings(), "", " ") + js, jsErr := json.MarshalIndent(viper.AllSettings(), "", " ") + if jsErr != nil { + log.WithError(jsErr).Panicln("error marshal indenting viper config to JSON") + } log.Debugf("Loaded configuration %s", js) } + lineup := newLineup() + + if scanErr := lineup.Scan(); scanErr != nil { + log.WithError(scanErr).Panicln("Error scanning lineup!") + } + + serve(lineup) +} + +func validateConfig() { if viper.IsSet("filter.regexstr") { if _, regexErr := regexp.Compile(viper.GetString("filter.regex")); regexErr != nil { log.WithError(regexErr).Panicln("Error when compiling regex, is it valid?") @@ -127,33 +185,17 @@ func main() { log.WithError(addrErr).Panic("Error when parsing Listen address, please check the address and try again.") return } + if _, addrErr = net.ResolveTCPAddr("tcp", viper.GetString("web.base-address")); addrErr != nil { log.WithError(addrErr).Panic("Error when parsing Base addresses, please check the address and try again.") return } - if GetTCPAddr("web.base-address").IP.IsUnspecified() { + if getTCPAddr("web.base-address").IP.IsUnspecified() { log.Panicln("base URL is set to 0.0.0.0, this will not work. please use the --web.baseaddress option and set it to the (local) ip address telly is running on.") } - if GetTCPAddr("web.listenaddress").IP.IsUnspecified() && GetTCPAddr("web.base-address").IP.IsLoopback() { + if getTCPAddr("web.listenaddress").IP.IsUnspecified() && getTCPAddr("web.base-address").IP.IsLoopback() { log.Warnln("You are listening on all interfaces but your base URL is localhost (meaning Plex will try and load localhost to access your streams) - is this intended?") } - - viper.Set("discovery.device-friendly-name", fmt.Sprintf("HDHomerun (%s)", viper.GetString("discovery.device-friendly-name"))) - viper.Set("discovery.device-uuid", fmt.Sprintf("%d-AE2A-4E54-BBC9-33AF7D5D6A92", viper.GetInt("discovery.device-id"))) - - if flag.Lookup("iptv.playlist").Changed { - viper.Set("playlists.default.m3u", flag.Lookup("iptv.playlist").Value.String()) - } - - lineup := NewLineup() - - log.Infof("Loaded %d channels into the lineup", lineup.FilteredTracksCount) - - if lineup.FilteredTracksCount > 420 { - log.Panicf("telly has loaded more than 420 channels (%d) into the lineup. Plex does not deal well with more than this amount and will more than likely hang when trying to fetch channels. You must use regular expressions to filter out channels. You can also start another Telly instance.", lineup.FilteredTracksCount) - } - - serve(lineup) } diff --git a/providers/iptv-epg.go b/providers/iptv-epg.go deleted file mode 100644 index d1af649..0000000 --- a/providers/iptv-epg.go +++ /dev/null @@ -1,4 +0,0 @@ -package providers - -// M3U: http://iptv-epg.com/.m3u -// XMLTV: http://iptv-epg.com/.xml diff --git a/providers/main.go b/providers/main.go deleted file mode 100644 index ad7bbe3..0000000 --- a/providers/main.go +++ /dev/null @@ -1,97 +0,0 @@ -package providers - -import ( - "fmt" - "regexp" - "strings" - - log "github.com/sirupsen/logrus" - "github.com/tombowditch/telly/m3u" -) - -var channelNumberExtractor = regexp.MustCompile(`/(\d+).(ts|.*.m3u8)`).FindAllStringSubmatch - -type Configuration struct { - Name string `json:"-"` - Provider string - - Username string `json:"username"` - Password string `json:"password"` - - M3U string `json:"-"` - EPG string `json:"-"` - - VideoOnDemand bool `json:"-"` -} - -func (i *Configuration) GetProvider() (Provider, error) { - switch strings.ToLower(i.Provider) { - case "vaders": - log.Infoln("Source is vaders!") - return newVaders(i) - case "custom": - default: - log.Infoln("source is either custom or unknown, assuming custom!") - } - return nil, nil -} - -// ProviderChannel describes a channel available in the providers lineup with necessary pieces parsed into fields. -type ProviderChannel struct { - Name string - InternalID int // Should be the integer just before .ts. - Number *int - Logo string - StreamURL string - HD bool - Quality string - OnDemand bool - StreamFormat string -} - -// Provider describes a IPTV provider configuration. -type Provider interface { - Name() string - PlaylistURL() string - EPGURL() string - - // These are functions to extract information from playlists. - ParseLine(line m3u.Track) (*ProviderChannel, error) - - AuthenticatedStreamURL(channel *ProviderChannel) string - - MatchPlaylistKey() string -} - -// UnmarshalProviders takes V, a slice of Configuration and transforms it into a slice of Provider. -func UnmarshalProviders(v interface{}) ([]Provider, error) { - providers := make([]Provider, 0) - - uncasted, ok := v.([]interface{}) - if !ok { - panic(fmt.Errorf("provided slice is not of type []Configuration, it is of type %T", v)) - } - - for _, uncastedProvider := range uncasted { - ipProvider := uncastedProvider.(Configuration) - log.Infof("ipProvider %+v", ipProvider) - } - - return providers, nil -} - -// func testProvider() { -// v, vErr := NewVadersTV("hunter1", "hunter2", false) -// if vErr != nil { -// log.WithError(vErr).Errorf("Error setting up %s", v.Name()) -// } -// log.Infoln("Provider name is", v.Name()) -// log.Infoln("Playlist URL is", v.PlaylistURL()) -// log.Infoln("EPG URL is", v.EPGURL()) -// log.Infof("Stream URL is %+v", v.AuthenticatedStreamURL(&ProviderChannel{ -// Name: "Test channel", -// InternalID: 2862, -// })) - -// return -// } diff --git a/providers/vaders.go b/providers/vaders.go deleted file mode 100644 index a324c39..0000000 --- a/providers/vaders.go +++ /dev/null @@ -1,72 +0,0 @@ -package providers - -import ( - "encoding/base64" - "encoding/json" - "fmt" - "strconv" - "strings" - - "github.com/tombowditch/telly/m3u" -) - -// M3U: http://api.vaders.tv/vget?username=xxx&password=xxx&format=ts -// XMLTV: http://vaders.tv/p2.xml - -type vader struct { - provider Configuration - - Token string `json:"-"` -} - -func newVaders(config *Configuration) (Provider, error) { - tok, tokErr := json.Marshal(config) - if tokErr != nil { - return nil, tokErr - } - - return &vader{*config, base64.StdEncoding.EncodeToString(tok)}, nil -} - -func (v *vader) Name() string { - return "Vaders.tv" -} - -func (v *vader) PlaylistURL() string { - return fmt.Sprintf("http://api.vaders.tv/vget?username=%s&password=%s&vod=%t&format=ts", v.provider.Username, v.provider.Password, v.provider.VideoOnDemand) -} - -func (v *vader) EPGURL() string { - return "http://vaders.tv/p2.xml" -} - -func (v *vader) ParseLine(line m3u.Track) (*ProviderChannel, error) { - streamURL := channelNumberExtractor(line.URI, -1)[0] - channelID, channelIDErr := strconv.Atoi(streamURL[1]) - if channelIDErr != nil { - return nil, channelIDErr - } - - // http://vapi.vaders.tv/play/dvr/${start}/TSID.ts?duration=3600&token= - // http://vapi.vaders.tv/play/TSID.ts?token= - // http://vapi.vaders.tv/play/vod/VODID.mp4.m3u8?token= - // http://vapi.vaders.tv/play/vod/VODID.avi.m3u8?token= - // http://vapi.vaders.tv/play/vod/VODID.mkv.m3u8?token= - - return &ProviderChannel{ - Name: line.Tags["tvg-name"], - Logo: line.Tags["tvg-logo"], - StreamURL: line.URI, - InternalID: channelID, - HD: strings.Contains(strings.ToLower(line.Tags["tvg-name"]), "hd"), - StreamFormat: streamURL[2], - }, nil -} - -func (v *vader) AuthenticatedStreamURL(channel *ProviderChannel) string { - return fmt.Sprintf("http://vapi.vaders.tv/play/%d.ts?token=%s", channel.InternalID, v.Token) -} - -func (v *vader) MatchPlaylistKey() string { - return "tvg-id" -} diff --git a/routes.go b/routes.go index 26a2012..774b499 100644 --- a/routes.go +++ b/routes.go @@ -5,24 +5,29 @@ import ( "fmt" "net/http" "sort" + "strconv" + "strings" "time" "github.com/gin-gonic/gin" ssdp "github.com/koron/go-ssdp" "github.com/sirupsen/logrus" "github.com/spf13/viper" - ginprometheus "github.com/zsais/go-gin-prometheus" + ginprometheus "github.com/tombowditch/telly/internal/go-gin-prometheus" + "github.com/tombowditch/telly/internal/xmltv" ) -func serve(lineup *Lineup) { - discoveryData := GetDiscoveryData() +func serve(lineup *lineup) { + discoveryData := getDiscoveryData() log.Debugln("creating device xml") upnp := discoveryData.UPNP() log.Debugln("creating webserver routes") - gin.SetMode(gin.ReleaseMode) + if viper.GetString("log.level") != logrus.DebugLevel.String() { + gin.SetMode(gin.ReleaseMode) + } router := gin.New() router.Use(gin.Recovery()) @@ -43,7 +48,7 @@ func serve(lineup *Lineup) { Source: "Cable", SourceList: []string{"Cable"}, } - if lineup.Refreshing { + if lineup.Scanning { payload = LineupStatus{ ScanInProgress: convertibleBoolean(true), // Gotta fake out Plex. @@ -57,7 +62,7 @@ func serve(lineup *Lineup) { router.POST("/lineup.post", func(c *gin.Context) { scanAction := c.Query("scan") if scanAction == "start" { - if refreshErr := lineup.Refresh(); refreshErr != nil { + if refreshErr := lineup.Scan(); refreshErr != nil { c.AbortWithError(http.StatusInternalServerError, refreshErr) } c.AbortWithStatus(http.StatusOK) @@ -70,6 +75,7 @@ func serve(lineup *Lineup) { }) router.GET("/device.xml", deviceXML(upnp)) router.GET("/lineup.json", serveLineup(lineup)) + router.GET("/lineup.xml", serveLineup(lineup)) router.GET("/auto/:channelID", stream(lineup)) router.GET("/epg.xml", xmlTV(lineup)) router.GET("/debug.json", func(c *gin.Context) { @@ -82,7 +88,9 @@ func serve(lineup *Lineup) { } } - log.Infof("Listening and serving HTTP on %s", viper.GetString("web.listen-address")) + log.Infof("telly is live and on the air!") + log.Infof("Broadcasting on %s", viper.GetString("web.listen-address")) + log.Infof("EPG URL: http://%s/epg.xml", viper.GetString("web.listen-address")) if err := router.Run(viper.GetString("web.listen-address")); err != nil { log.WithError(err).Panicln("Error starting up web server") } @@ -100,40 +108,71 @@ func discovery(data DiscoveryData) gin.HandlerFunc { } } -func lineupStatus(status LineupStatus) gin.HandlerFunc { - return func(c *gin.Context) { - c.JSON(http.StatusOK, status) - } +type hdhrLineupContainer struct { + XMLName xml.Name `xml:"Lineup" json:"-"` + Programs []hdHomeRunLineupItem } -func serveLineup(lineup *Lineup) gin.HandlerFunc { +func serveLineup(lineup *lineup) gin.HandlerFunc { return func(c *gin.Context) { - allChannels := make([]HDHomeRunChannel, 0) - for _, playlist := range lineup.Playlists { - allChannels = append(allChannels, playlist.Channels...) + channels := make([]hdHomeRunLineupItem, 0) + for _, channel := range lineup.channels { + channels = append(channels, channel) + } + sort.Slice(channels, func(i, j int) bool { + return channels[i].GuideNumber < channels[j].GuideNumber + }) + if strings.HasSuffix(c.Request.URL.String(), ".xml") { + buf, marshallErr := xml.MarshalIndent(hdhrLineupContainer{Programs: channels}, "", "\t") + if marshallErr != nil { + c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("error marshalling lineup to XML")) + } + c.Data(http.StatusOK, "application/xml", []byte(``+"\n"+string(buf))) + return } - sort.Slice(allChannels, func(i, j int) bool { return allChannels[i].GuideNumber < allChannels[j].GuideNumber }) - c.JSON(http.StatusOK, allChannels) + c.JSON(http.StatusOK, channels) } } -func xmlTV(lineup *Lineup) gin.HandlerFunc { +func xmlTV(lineup *lineup) gin.HandlerFunc { + epg := &xmltv.TV{ + GeneratorInfoName: namespaceWithVersion, + GeneratorInfoURL: "https://github.com/tombowditch/telly", + } + + for _, channel := range lineup.channels { + if channel.providerChannel.EPGChannel != nil { + epg.Channels = append(epg.Channels, *channel.providerChannel.EPGChannel) + epg.Programmes = append(epg.Programmes, channel.providerChannel.EPGProgrammes...) + } + } + + sort.Slice(epg.Channels, func(i, j int) bool { return epg.Channels[i].LCN < epg.Channels[j].LCN }) + return func(c *gin.Context) { - buf, _ := xml.MarshalIndent(lineup.xmlTv, "", "\t") + buf, marshallErr := xml.MarshalIndent(epg, "", "\t") + if marshallErr != nil { + c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("error marshalling EPG to XML")) + } c.Data(http.StatusOK, "application/xml", []byte(xml.Header+``+"\n"+string(buf))) } } -func stream(lineup *Lineup) gin.HandlerFunc { +func stream(lineup *lineup) gin.HandlerFunc { return func(c *gin.Context) { - channelID := c.Param("channelID")[1:] + channelIDStr := c.Param("channelID")[1:] + channelID, channelIDErr := strconv.Atoi(channelIDStr) + if channelIDErr != nil { + c.AbortWithError(http.StatusBadRequest, fmt.Errorf("that (%s) doesn't appear to be a valid channel number", channelIDStr)) + return + } - if url, ok := lineup.chanNumToURLMap[channelID]; ok { - log.Infof("Serving channel number %s", channelID) - c.Redirect(http.StatusMovedPermanently, url) + if channel, ok := lineup.channels[channelID]; ok { + log.Infof("Serving channel number %d", channelID) + c.Redirect(http.StatusMovedPermanently, channel.providerChannel.Track.URI) return } - c.AbortWithError(http.StatusNotFound, fmt.Errorf("unknown channel number %s", channelID)) + c.AbortWithError(http.StatusNotFound, fmt.Errorf("unknown channel number %d", channelID)) } } diff --git a/structs.go b/structs.go index 40d169b..c3977a3 100644 --- a/structs.go +++ b/structs.go @@ -4,51 +4,8 @@ import ( "encoding/json" "encoding/xml" "fmt" - "net" - "regexp" ) -type config struct { - Filter struct { - RegexInclusive bool `toml:"Filter.RegexInclusive"` - Regex *regexp.Regexp `toml:"-"` - RegexStr string `toml:"Filter.Regex"` - } - - IPTV struct { - Playlists []string `toml:"IPTV.Playlists"` - ConcurrentStreams int `toml:"IPTV.ConcurrentStreams"` - StartingChannel int `toml:"IPTV.StartingChannel"` - XMLTVChannelNumbers bool `toml:"IPTV.XMLTVChannelNumbers"` - } - - Discovery struct { - DeviceAuth string `toml:"Discovery.DeviceAuth"` - DeviceID int `toml:"Discovery.DeviceID"` - DeviceUUID string `toml:"Discovery.DeviceUUID"` - FriendlyName string `toml:"Discovery.FriendlyName"` - Manufacturer string `toml:"Discovery.Manufacturer"` - ModelNumber string `toml:"Discovery.ModelNumber"` - FirmwareName string `toml:"Discovery.FirmwareName"` - FirmwareVersion string `toml:"Discovery.FirmwareVersion"` - SSDP bool `toml:"Discovery.SSDP"` - } - - Log struct { - LogRequests bool `toml:"Log.Requests"` - Level string `toml:"Log.Level"` - } - - Web struct { - ListenAddress *net.TCPAddr `toml:"-"` - BaseAddress *net.TCPAddr `toml:"-"` - ListenAddressStr string `toml:"Web.ListenAddress"` - BaseAddressStr string `toml:"Web.BaseAddress"` - } - - lineup *Lineup -} - // DiscoveryData contains data about telly to expose in the HDHomeRun format for Plex detection. type DiscoveryData struct { FriendlyName string @@ -136,3 +93,29 @@ func (bit *convertibleBoolean) UnmarshalJSON(data []byte) error { } return nil } + +// MarshalXML used to determine if the element is present or not. see https://stackoverflow.com/a/46516243 +func (bit *convertibleBoolean) MarshalXML(e *xml.Encoder, start xml.StartElement) error { + var bitSetVar int8 + if *bit { + bitSetVar = 1 + } + + return e.EncodeElement(bitSetVar, start) +} + +// UnmarshalXML used to determine if the element is present or not. see https://stackoverflow.com/a/46516243 +func (bit *convertibleBoolean) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { + var asString string + if decodeErr := d.DecodeElement(&asString, &start); decodeErr != nil { + return decodeErr + } + if asString == "1" || asString == "true" { + *bit = true + } else if asString == "0" || asString == "false" { + *bit = false + } else { + return fmt.Errorf("Boolean unmarshal error: invalid input %s", asString) + } + return nil +} diff --git a/utils.go b/utils.go index a106b96..2bddd71 100644 --- a/utils.go +++ b/utils.go @@ -3,28 +3,26 @@ package main import ( "fmt" "net" - "regexp" "strconv" "github.com/spf13/viper" ) -func GetTCPAddr(key string) *net.TCPAddr { - addr, _ := net.ResolveTCPAddr("tcp", viper.GetString(key)) +func getTCPAddr(key string) *net.TCPAddr { + addr, addrErr := net.ResolveTCPAddr("tcp", viper.GetString(key)) + if addrErr != nil { + panic(fmt.Errorf("error parsing address %s: %s", viper.GetString(key), addrErr)) + } return addr } -func GetStringAsRegex(key string) *regexp.Regexp { - return regexp.MustCompile(viper.GetString(key)) -} - -func GetDiscoveryData() DiscoveryData { +func getDiscoveryData() DiscoveryData { return DiscoveryData{ FriendlyName: viper.GetString("discovery.device-friendly-name"), Manufacturer: viper.GetString("discovery.device-manufacturer"), ModelNumber: viper.GetString("discovery.device-model-number"), FirmwareName: viper.GetString("discovery.device-firmware-name"), - TunerCount: viper.GetInt("iptv.concurrent-streams"), + TunerCount: viper.GetInt("iptv.streams"), FirmwareVersion: viper.GetString("discovery.device-firmware-version"), DeviceID: strconv.Itoa(viper.GetInt("discovery.device-id")), DeviceAuth: viper.GetString("discovery.device-auth"), From 77985f93e2ff0ae2bdab8fd984901c88d2e8ce29 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Wed, 15 Aug 2018 18:47:03 -0700 Subject: [PATCH 008/114] Minor fixes around logging, XMLTV and more --- internal/providers/vaders.go | 4 ++++ internal/xmltv/xmltv.go | 2 +- lineup.go | 43 +++++++++++++++++++----------------- routes.go | 2 +- 4 files changed, 29 insertions(+), 22 deletions(-) diff --git a/internal/providers/vaders.go b/internal/providers/vaders.go index bcb2fa6..9b0502f 100644 --- a/internal/providers/vaders.go +++ b/internal/providers/vaders.go @@ -117,10 +117,14 @@ func (v *vader) ParseTrack(track m3u.Track, channelMap map[string]xmltv.Channel) } func (v *vader) ProcessProgramme(programme xmltv.Programme) *xmltv.Programme { + isNew := false for idx, title := range programme.Titles { + isNew = strings.HasSuffix(title.Value, " [New!]") programme.Titles[idx].Value = strings.Replace(title.Value, " [New!]", "", -1) } + programme.New = xmltv.ElementPresent(isNew) + return &programme } diff --git a/internal/xmltv/xmltv.go b/internal/xmltv/xmltv.go index 5567f1f..f75e3c1 100644 --- a/internal/xmltv/xmltv.go +++ b/internal/xmltv/xmltv.go @@ -93,7 +93,7 @@ type Programme struct { PreviouslyShown *PreviouslyShown `xml:"previously-shown,omitempty" json:"previously_shown,omitempty"` Premiere *CommonElement `xml:"premiere,omitempty" json:"premiere,omitempty"` LastChance *CommonElement `xml:"last-chance,omitempty" json:"last_chance,omitempty"` - New ElementPresent `xml:"new>placeholder,omitempty" json:"new"` + New ElementPresent `xml:"new>placeholder,omitempty" json:"new,omitempty"` Subtitles []Subtitle `xml:"subtitles,omitempty" json:"subtitles,omitempty"` Ratings []Rating `xml:"rating,omitempty" json:"ratings,omitempty"` StarRatings []Rating `xml:"star-rating,omitempty" json:"star_ratings,omitempty"` diff --git a/lineup.go b/lineup.go index 3881f7c..d682687 100644 --- a/lineup.go +++ b/lineup.go @@ -73,7 +73,7 @@ func newLineup() *lineup { log.WithError(unmarshalErr).Panicln("Unable to unmarshal source configuration to slice of providers.Configuration, check your configuration!") } - if viper.IsSet("iptv.playlist") { + if viper.GetString("iptv.playlist") != "" { log.Warnln("Legacy --iptv.playlist argument or environment variable provided, using Custom provider with default configuration, this may fail! If so, you should use a configuration file for full flexibility.") regexStr := ".*" if viper.IsSet("filter.regex") { @@ -153,11 +153,16 @@ func (l *lineup) processProvider(provider providers.Provider) (int, error) { }) } + successChannels := []string{} + failedChannels := []string{} + for _, track := range m3u.Tracks { // First, we run the filter. if !l.FilterTrack(provider, track) { - log.Debugf("Channel %s didn't pass the provider (%s) filter, skipping!", track.Name, provider.Name()) - return addedChannels, nil + failedChannels = append(failedChannels, track.Name) + continue + } else { + successChannels = append(successChannels, track.Name) } // Then we do the provider specific translation to a hdHomeRunLineupItem. @@ -178,6 +183,9 @@ func (l *lineup) processProvider(provider providers.Provider) (int, error) { l.channels[channel.Number] = newHDHRItem(&provider, channel) } + log.Debugf("These channels (%d) passed the filter and successfully parsed: %s", len(successChannels), strings.Join(successChannels, ", ")) + log.Debugf("These channels (%d) did NOT pass the filter: %s", len(failedChannels), strings.Join(failedChannels, ", ")) + log.Infof("Loaded %d channels into the lineup from %s", addedChannels, provider.Name()) return addedChannels, nil @@ -198,6 +206,10 @@ func (l *lineup) prepareProvider(provider providers.Provider) (*m3u.Playlist, ma return nil, nil, nil, err } + if closeM3UErr := reader.Close(); closeM3UErr != nil { + log.WithError(closeM3UErr).Panicln("error when closing m3u reader") + } + channelMap, programmeMap, epgErr := l.prepareEPG(provider, cacheFiles) if epgErr != nil { log.WithError(epgErr).Errorln("error when parsing EPG") @@ -285,12 +297,13 @@ func (l *lineup) prepareEPG(provider providers.Provider, cacheFiles bool) (map[s } } } + } return epgChannelMap, epgProgrammeMap, nil } -func getM3U(path string, cacheFiles bool) (io.Reader, error) { +func getM3U(path string, cacheFiles bool) (io.ReadCloser, error) { safePath := safeStringsRegex.ReplaceAllStringFunc(path, stringSafer) log.Infof("Loading M3U from %s", safePath) @@ -317,10 +330,14 @@ func getXMLTV(path string, cacheFiles bool) (*xmltv.TV, error) { return nil, err } + if closeXMLErr := file.Close(); closeXMLErr != nil { + log.WithError(closeXMLErr).Panicln("error when closing xml reader") + } + return tvSetup, nil } -func getFile(path string, cacheFiles bool) (io.Reader, string, error) { +func getFile(path string, cacheFiles bool) (io.ReadCloser, string, error) { transport := "disk" if strings.HasPrefix(strings.ToLower(path), "http") { @@ -330,13 +347,6 @@ func getFile(path string, cacheFiles bool) (io.Reader, string, error) { return nil, transport, err } - // defer func() { - // err := resp.Body.Close() - // if err != nil { - // log.WithError(err).Panicln("error when closing HTTP body reader") - // } - // }() - if strings.HasSuffix(strings.ToLower(path), ".gz") { log.Infof("File (%s) is gzipp'ed, ungzipping now, this might take a while", path) gz, gzErr := gzip.NewReader(resp.Body) @@ -344,13 +354,6 @@ func getFile(path string, cacheFiles bool) (io.Reader, string, error) { return nil, transport, gzErr } - defer func() { - err := gz.Close() - if err != nil { - log.WithError(err).Panicln("error when closing gzip reader") - } - }() - if cacheFiles { return writeFile(path, transport, gz) } @@ -373,7 +376,7 @@ func getFile(path string, cacheFiles bool) (io.Reader, string, error) { return file, transport, nil } -func writeFile(path, transport string, reader io.Reader) (io.Reader, string, error) { +func writeFile(path, transport string, reader io.ReadCloser) (io.ReadCloser, string, error) { // buf := new(bytes.Buffer) // buf.ReadFrom(reader) // buf.Bytes() diff --git a/routes.go b/routes.go index 774b499..d708090 100644 --- a/routes.go +++ b/routes.go @@ -89,7 +89,7 @@ func serve(lineup *lineup) { } log.Infof("telly is live and on the air!") - log.Infof("Broadcasting on %s", viper.GetString("web.listen-address")) + log.Infof("Broadcasting from http://%s/", viper.GetString("web.listen-address")) log.Infof("EPG URL: http://%s/epg.xml", viper.GetString("web.listen-address")) if err := router.Run(viper.GetString("web.listen-address")); err != nil { log.WithError(err).Panicln("Error starting up web server") From 3e09bbd81a2aa206028dfa36d03cfcdedd463e40 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Thu, 16 Aug 2018 15:08:07 -0700 Subject: [PATCH 009/114] Checkpoint on Schedules Direct before migrating to new repo --- internal/xmltv/xmltv.go | 23 +++- lineup.go | 243 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 265 insertions(+), 1 deletion(-) diff --git a/internal/xmltv/xmltv.go b/internal/xmltv/xmltv.go index f75e3c1..37c70c2 100644 --- a/internal/xmltv/xmltv.go +++ b/internal/xmltv/xmltv.go @@ -33,6 +33,27 @@ func (t *Time) UnmarshalXMLAttr(attr xml.Attr) error { return nil } +type Date struct { + time.Time +} + +func (c *Date) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { + var v string + if decodeErr := d.DecodeElement(&v, &start); decodeErr != nil { + return decodeErr + } + dateFormat := "20060102" // yyyymmdd date format + if len(v) == 4 { + dateFormat = "2006" + } + parse, err := time.ParseInLocation(dateFormat, v, time.UTC) + if err != nil { + return err + } + *c = Date{parse} + return nil +} + // TV is the root element. type TV struct { XMLName xml.Name `xml:"tv" json:"-"` @@ -78,7 +99,7 @@ type Programme struct { SecondaryTitles []CommonElement `xml:"sub-title,omitempty" json:"secondary_titles,omitempty"` Descriptions []CommonElement `xml:"desc,omitempty" json:"descriptions,omitempty"` Credits *Credits `xml:"credits,omitempty" json:"credits,omitempty"` - Date string `xml:"date,omitempty" json:"date,omitempty"` + Date Date `xml:"date,omitempty" json:"date,omitempty"` Categories []CommonElement `xml:"category,omitempty" json:"categories,omitempty"` Keywords []CommonElement `xml:"keyword,omitempty" json:"keywords,omitempty"` Languages []CommonElement `xml:"language,omitempty" json:"languages,omitempty"` diff --git a/lineup.go b/lineup.go index d682687..7153de0 100644 --- a/lineup.go +++ b/lineup.go @@ -9,8 +9,10 @@ import ( "os" "regexp" "sort" + "strconv" "strings" + "github.com/nathanjjohnson/GoSchedulesDirect" "github.com/spf13/viper" m3u "github.com/tombowditch/telly/internal/m3uplus" "github.com/tombowditch/telly/internal/providers" @@ -20,6 +22,7 @@ import ( // var channelNumberRegex = regexp.MustCompile(`^[0-9]+[[:space:]]?$`).MatchString // var callSignRegex = regexp.MustCompile(`^[A-Z0-9]+$`).MatchString // var hdRegex = regexp.MustCompile(`hd|4k`) +var xmlNSRegex = regexp.MustCompile(`(\d).(\d).(?:(\d)/(\d))?`) // hdHomeRunLineupItem is a HDHomeRun specification compatible representation of a Track available in the lineup. type hdHomeRunLineupItem struct { @@ -63,6 +66,8 @@ type lineup struct { xmlTVChannelNumbers bool channels map[int]hdHomeRunLineupItem + + sd *GoSchedulesDirect.Client } // newLineup returns a new lineup for the given config struct. @@ -94,6 +99,14 @@ func newLineup() *lineup { channels: make(map[int]hdHomeRunLineupItem), } + lineup.sd = GoSchedulesDirect.NewClient(viper.GetString("schedulesdirect.username"), viper.GetString("schedulesdirect.password")) + + status, statusErr := lineup.sd.GetStatus() + if statusErr != nil { + panic(statusErr) + } + log.Infof("SD status %+v", status) + for _, cfg := range cfgs { provider, providerErr := cfg.GetProvider() if providerErr != nil { @@ -288,16 +301,61 @@ func (l *lineup) prepareEPG(provider providers.Provider, cacheFiles bool) (map[s return epgChannelMap, epgProgrammeMap, epgErr } + needsMoreInfo := make(map[string]xmltv.Programme) // TMSID:programme + haveAllInfo := make(map[string][]xmltv.Programme) // channel number:[]programme + for _, channel := range epg.Channels { epgChannelMap[channel.ID] = channel for _, programme := range epg.Programmes { if programme.Channel == channel.ID { epgProgrammeMap[channel.ID] = append(epgProgrammeMap[channel.ID], *provider.ProcessProgramme(programme)) + if len(programme.EpisodeNums) == 1 && programme.EpisodeNums[0].System == "dd_progid" { + needsMoreInfo[programme.EpisodeNums[0].Value] = programme + } else { + haveAllInfo[channel.ID] = append(haveAllInfo[channel.ID], *provider.ProcessProgramme(programme)) + } } } } + tmsIDs := make([]string, 0) + + // r := strings.NewReplacer("/", "", ".", "") + + for tmsID := range needsMoreInfo { + splitID := strings.Split(tmsID, ".") + tmsIDs = append(tmsIDs, fmt.Sprintf("%s%s", splitID[0], splitID[1])) + } + + log.Infof("GETTING %d programs from SD", len(tmsIDs)) + + //ids := []string{"EP00000204.0125.0/2", "EP00000204.0126.1/2", "EP03022620.0011.0/3", "EP03022786.0001", "EP03022786.0001", "EP03022786.0001", "EP03022786.0001", "EP03023628.0001", "EP03023750.0001", "EP03023787.0001", "EP03023787.0002", "EP03023971.0001", "EP03025363.0001", "EP03025363.0002", "EP03025363.0003", "EP03025363.0004", "EP03025363.0005", "EP03025363.0006", "EP03026541.0001", "EP03026541.0001", "EP03026541.0001", "EP03027284.0005", "EP03027284.0005", "EP03029229.0001", "MV00000031.0000", "SH00246313.0000", "SH02485979.0000.0/3", "SH02485979.0000.1/3"} + + allResponses := make([]GoSchedulesDirect.ProgramInfo, len(tmsIDs)) + + for _, chunk := range chunkStringSlice(tmsIDs, 5000) { + moreInfo, moreInfoErr := l.sd.GetProgramInfo(chunk) + if moreInfoErr != nil { + log.WithError(moreInfoErr).Errorln("Error when getting more program details from Schedules Direct") + return epgChannelMap, epgProgrammeMap, moreInfoErr + } + + allResponses = append(allResponses, moreInfo...) + } + + log.Infoln("Got %d responses from SD", len(allResponses)) + + for _, program := range allResponses { + newProgram := MergeSchedulesDirectAndXMLTVProgramme(needsMoreInfo[program.ProgramID], program) + log.Infof("newProgram %+v") + } + + //panic("bye") + + // needsMoreInfo + //epgProgrammeMap[channel.ID] = append(epgProgrammeMap[channel.ID], *provider.ProcessProgramme(programme)) + } return epgChannelMap, epgProgrammeMap, nil @@ -391,3 +449,188 @@ func containsIcon(s []xmltv.Icon, e string) bool { } return false } + +func chunkStringSlice(sl []string, chunkSize int) [][]string { + var divided [][]string + + for i := 0; i < len(sl); i += chunkSize { + end := i + chunkSize + + if end > len(sl) { + end = len(sl) + } + + divided = append(divided, sl[i:end]) + } + return divided +} + +func MergeSchedulesDirectAndXMLTVProgramme(programme xmltv.Programme, sdProgram GoSchedulesDirect.ProgramInfo) xmltv.Programme { + + allTitles := make([]string, 0) + + for _, title := range programme.Titles { + allTitles = append(allTitles, title.Value) + } + + for _, title := range sdProgram.Titles { + allTitles = append(allTitles, title.Title120) + } + + for _, title := range UniqueStrings(allTitles) { + programme.Titles = append(programme.Titles, xmltv.CommonElement{Value: title}) + } + + allKeywords := make([]string, 0) + + for _, keyword := range programme.Keywords { + allKeywords = append(allKeywords, keyword.Value) + } + + for keywordType, keywords := range sdProgram.Keywords { + log.Infoln("Adding keywords category", keywordType) + for _, keyword := range keywords { + allKeywords = append(allKeywords, keyword) + } + } + + // FIXME: We should really be making sure that we passthrough languages. + allDescriptions := make([]string, 0) + + for _, description := range programme.Descriptions { + allDescriptions = append(allDescriptions, description.Value) + } + + for _, descriptions := range sdProgram.Descriptions { + for _, description := range descriptions { + allDescriptions = append(allDescriptions, description.Description) + } + } + + for _, description := range UniqueStrings(allDescriptions) { + programme.Descriptions = append(programme.Descriptions, xmltv.CommonElement{Value: description}) + } + + for _, keyword := range UniqueStrings(allKeywords) { + programme.Keywords = append(programme.Keywords, xmltv.CommonElement{Value: keyword}) + } + + allRatings := make(map[string]string, 0) + + for _, rating := range programme.Ratings { + allRatings[rating.System] = rating.Value + } + + for _, rating := range sdProgram.ContentRating { + allRatings[rating.Body] = rating.Code + } + + for system, rating := range allRatings { + programme.Ratings = append(programme.Ratings, xmltv.Rating{Value: rating, System: system}) + } + + hasXMLTVNS := false + + for _, epNum := range programme.EpisodeNums { + if epNum.System == "xmltv_ns" { + hasXMLTVNS = true + } + } + + if !hasXMLTVNS { + seasonNumber := 0 + episodeNumber := 0 + numbersFilled := false + + for _, meta := range sdProgram.Metadata { + for _, metadata := range meta { + if metadata.Season != nil { + seasonNumber = *metadata.Season - 1 // SD metadata isnt 0 index + numbersFilled = true + } + if metadata.Episode != nil { + episodeNumber = *metadata.Episode - 1 + numbersFilled = true + } + } + } + + if numbersFilled { + // FIXME: There is currently no way to determine multipart episodes from SD. + // We could use the dd_progid to determine it though. + xmlTVNS := fmt.Sprintf("%d.%d.0/1", seasonNumber, episodeNumber) + programme.EpisodeNums = append(programme.EpisodeNums, xmltv.EpisodeNum{System: "xmltv_ns", Value: xmlTVNS}) + } + } + + return programme +} + +func extractXMLTVNS(str string) (int, int, int, int, error) { + matches := xmlNSRegex.FindAllStringSubmatch(str, -1) + + if len(matches) == 0 { + return 0, 0, 0, 0, fmt.Errorf("invalid xmltv_ns: %s", str) + } + + season, seasonErr := strconv.Atoi(matches[0][1]) + if seasonErr != nil { + return 0, 0, 0, 0, seasonErr + } + + episode, episodeErr := strconv.Atoi(matches[0][2]) + if episodeErr != nil { + return 0, 0, 0, 0, episodeErr + } + + currentPartNum := 0 + totalPartsNum := 0 + + if len(matches[0]) > 2 && matches[0][3] != "" { + currentPart, currentPartErr := strconv.Atoi(matches[0][3]) + if currentPartErr != nil { + return 0, 0, 0, 0, currentPartErr + } + currentPartNum = currentPart + } + + if len(matches[0]) > 3 && matches[0][4] != "" { + totalParts, totalPartsErr := strconv.Atoi(matches[0][4]) + if totalPartsErr != nil { + return 0, 0, 0, 0, totalPartsErr + } + totalPartsNum = totalParts + } + + // if season > 0 { + // season = season - 1 + // } + + // if episode > 0 { + // episode = episode - 1 + // } + + // if currentPartNum > 0 { + // currentPartNum = currentPartNum - 1 + // } + + // if totalPartsNum > 0 { + // totalPartsNum = totalPartsNum - 1 + // } + + return season, episode, currentPartNum, totalPartsNum, nil +} + +func UniqueStrings(input []string) []string { + u := make([]string, 0, len(input)) + m := make(map[string]bool) + + for _, val := range input { + if _, ok := m[val]; !ok { + m[val] = true + u = append(u, val) + } + } + + return u +} From 356841a1e9b6d2e012cea4d263d327fded9b508a Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Thu, 16 Aug 2018 15:12:55 -0700 Subject: [PATCH 010/114] Migrate to new repo --- internal/providers/custom.go | 4 ++-- internal/providers/iptv-epg.go | 4 ++-- internal/providers/main.go | 4 ++-- internal/providers/vaders.go | 4 ++-- lineup.go | 8 ++++---- routes.go | 6 +++--- 6 files changed, 15 insertions(+), 15 deletions(-) diff --git a/internal/providers/custom.go b/internal/providers/custom.go index e6a93c0..721ddc9 100644 --- a/internal/providers/custom.go +++ b/internal/providers/custom.go @@ -4,8 +4,8 @@ import ( "strconv" "strings" - m3u "github.com/tombowditch/telly/internal/m3uplus" - "github.com/tombowditch/telly/internal/xmltv" + m3u "github.com/tellytv/telly/internal/m3uplus" + "github.com/tellytv/telly/internal/xmltv" ) type customProvider struct { diff --git a/internal/providers/iptv-epg.go b/internal/providers/iptv-epg.go index 63c5602..258239b 100644 --- a/internal/providers/iptv-epg.go +++ b/internal/providers/iptv-epg.go @@ -5,8 +5,8 @@ import ( "strconv" "strings" - m3u "github.com/tombowditch/telly/internal/m3uplus" - "github.com/tombowditch/telly/internal/xmltv" + m3u "github.com/tellytv/telly/internal/m3uplus" + "github.com/tellytv/telly/internal/xmltv" ) // M3U: http://iptv-epg.com/.m3u diff --git a/internal/providers/main.go b/internal/providers/main.go index f772532..5931408 100644 --- a/internal/providers/main.go +++ b/internal/providers/main.go @@ -4,8 +4,8 @@ import ( "regexp" "strings" - m3u "github.com/tombowditch/telly/internal/m3uplus" - "github.com/tombowditch/telly/internal/xmltv" + m3u "github.com/tellytv/telly/internal/m3uplus" + "github.com/tellytv/telly/internal/xmltv" ) var streamNumberRegex = regexp.MustCompile(`/(\d+).(ts|.*.m3u8)`).FindAllStringSubmatch diff --git a/internal/providers/vaders.go b/internal/providers/vaders.go index 9b0502f..40d705b 100644 --- a/internal/providers/vaders.go +++ b/internal/providers/vaders.go @@ -9,8 +9,8 @@ import ( "strings" log "github.com/sirupsen/logrus" - m3u "github.com/tombowditch/telly/internal/m3uplus" - "github.com/tombowditch/telly/internal/xmltv" + m3u "github.com/tellytv/telly/internal/m3uplus" + "github.com/tellytv/telly/internal/xmltv" ) // This regex matches and extracts the following URLs. diff --git a/lineup.go b/lineup.go index 7153de0..698f6ed 100644 --- a/lineup.go +++ b/lineup.go @@ -14,9 +14,9 @@ import ( "github.com/nathanjjohnson/GoSchedulesDirect" "github.com/spf13/viper" - m3u "github.com/tombowditch/telly/internal/m3uplus" - "github.com/tombowditch/telly/internal/providers" - "github.com/tombowditch/telly/internal/xmltv" + m3u "github.com/tellytv/telly/internal/m3uplus" + "github.com/tellytv/telly/internal/providers" + "github.com/tellytv/telly/internal/xmltv" ) // var channelNumberRegex = regexp.MustCompile(`^[0-9]+[[:space:]]?$`).MatchString @@ -348,7 +348,7 @@ func (l *lineup) prepareEPG(provider providers.Provider, cacheFiles bool) (map[s for _, program := range allResponses { newProgram := MergeSchedulesDirectAndXMLTVProgramme(needsMoreInfo[program.ProgramID], program) - log.Infof("newProgram %+v") + log.Infof("newProgram %+v", newProgram) } //panic("bye") diff --git a/routes.go b/routes.go index d708090..203dcef 100644 --- a/routes.go +++ b/routes.go @@ -13,8 +13,8 @@ import ( ssdp "github.com/koron/go-ssdp" "github.com/sirupsen/logrus" "github.com/spf13/viper" - ginprometheus "github.com/tombowditch/telly/internal/go-gin-prometheus" - "github.com/tombowditch/telly/internal/xmltv" + ginprometheus "github.com/tellytv/telly/internal/go-gin-prometheus" + "github.com/tellytv/telly/internal/xmltv" ) func serve(lineup *lineup) { @@ -137,7 +137,7 @@ func serveLineup(lineup *lineup) gin.HandlerFunc { func xmlTV(lineup *lineup) gin.HandlerFunc { epg := &xmltv.TV{ GeneratorInfoName: namespaceWithVersion, - GeneratorInfoURL: "https://github.com/tombowditch/telly", + GeneratorInfoURL: "https://github.com/tellytv/telly", } for _, channel := range lineup.channels { From 102cb5deff6c9a4f56a031a35a976d3c3331c2ae Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Thu, 16 Aug 2018 15:42:43 -0700 Subject: [PATCH 011/114] Finish hooking in SD --- lineup.go | 54 ++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 34 insertions(+), 20 deletions(-) diff --git a/lineup.go b/lineup.go index 698f6ed..168f055 100644 --- a/lineup.go +++ b/lineup.go @@ -99,12 +99,17 @@ func newLineup() *lineup { channels: make(map[int]hdHomeRunLineupItem), } - lineup.sd = GoSchedulesDirect.NewClient(viper.GetString("schedulesdirect.username"), viper.GetString("schedulesdirect.password")) + sdClient := GoSchedulesDirect.NewClient(viper.GetString("schedulesdirect.username"), viper.GetString("schedulesdirect.password")) - status, statusErr := lineup.sd.GetStatus() + // FIXME: Check that SD is online before continuing. + + status, statusErr := sdClient.GetStatus() if statusErr != nil { panic(statusErr) } + + lineup.sd = sdClient + log.Infof("SD status %+v", status) for _, cfg := range cfgs { @@ -301,7 +306,7 @@ func (l *lineup) prepareEPG(provider providers.Provider, cacheFiles bool) (map[s return epgChannelMap, epgProgrammeMap, epgErr } - needsMoreInfo := make(map[string]xmltv.Programme) // TMSID:programme + sdEligible := make(map[string]xmltv.Programme) // TMSID:programme haveAllInfo := make(map[string][]xmltv.Programme) // channel number:[]programme for _, channel := range epg.Channels { @@ -310,8 +315,16 @@ func (l *lineup) prepareEPG(provider providers.Provider, cacheFiles bool) (map[s for _, programme := range epg.Programmes { if programme.Channel == channel.ID { epgProgrammeMap[channel.ID] = append(epgProgrammeMap[channel.ID], *provider.ProcessProgramme(programme)) - if len(programme.EpisodeNums) == 1 && programme.EpisodeNums[0].System == "dd_progid" { - needsMoreInfo[programme.EpisodeNums[0].Value] = programme + ddProgID := "" + if viper.IsSet("schedulesdirect.username") && viper.IsSet("schedulesdirect.password") { + for _, epNum := range programme.EpisodeNums { + if epNum.System == "dd_progid" { + ddProgID = epNum.Value + } + } + } + if ddProgID != "" { + sdEligible[ddProgID] = programme } else { haveAllInfo[channel.ID] = append(haveAllInfo[channel.ID], *provider.ProcessProgramme(programme)) } @@ -321,16 +334,16 @@ func (l *lineup) prepareEPG(provider providers.Provider, cacheFiles bool) (map[s tmsIDs := make([]string, 0) - // r := strings.NewReplacer("/", "", ".", "") - - for tmsID := range needsMoreInfo { - splitID := strings.Split(tmsID, ".") - tmsIDs = append(tmsIDs, fmt.Sprintf("%s%s", splitID[0], splitID[1])) + for tmsID := range sdEligible { + cleanID := strings.Replace(tmsID, ".", "", -1) + if len(cleanID) < 14 { + log.Warnf("found an invalid TMS ID/dd_progid: %s", cleanID) + continue + } + tmsIDs = append(tmsIDs, cleanID[0:13]) } - log.Infof("GETTING %d programs from SD", len(tmsIDs)) - - //ids := []string{"EP00000204.0125.0/2", "EP00000204.0126.1/2", "EP03022620.0011.0/3", "EP03022786.0001", "EP03022786.0001", "EP03022786.0001", "EP03022786.0001", "EP03023628.0001", "EP03023750.0001", "EP03023787.0001", "EP03023787.0002", "EP03023971.0001", "EP03025363.0001", "EP03025363.0002", "EP03025363.0003", "EP03025363.0004", "EP03025363.0005", "EP03025363.0006", "EP03026541.0001", "EP03026541.0001", "EP03026541.0001", "EP03027284.0005", "EP03027284.0005", "EP03029229.0001", "MV00000031.0000", "SH00246313.0000", "SH02485979.0000.0/3", "SH02485979.0000.1/3"} + log.Infof("Requesting guide data for %d programs from Schedules Direct", len(tmsIDs)) allResponses := make([]GoSchedulesDirect.ProgramInfo, len(tmsIDs)) @@ -346,15 +359,16 @@ func (l *lineup) prepareEPG(provider providers.Provider, cacheFiles bool) (map[s log.Infoln("Got %d responses from SD", len(allResponses)) - for _, program := range allResponses { - newProgram := MergeSchedulesDirectAndXMLTVProgramme(needsMoreInfo[program.ProgramID], program) - log.Infof("newProgram %+v", newProgram) + for _, sdResponse := range allResponses { + mergedProgramme := MergeSchedulesDirectAndXMLTVProgramme(sdEligible[sdResponse.ProgramID], sdResponse) + haveAllInfo[mergedProgramme.Channel] = append(haveAllInfo[mergedProgramme.Channel], mergedProgramme) } - //panic("bye") - - // needsMoreInfo - //epgProgrammeMap[channel.ID] = append(epgProgrammeMap[channel.ID], *provider.ProcessProgramme(programme)) + for _, programmes := range haveAllInfo { + for _, programme := range programmes { + epgProgrammeMap[programme.Channel] = append(epgProgrammeMap[programme.Channel], *provider.ProcessProgramme(programme)) + } + } } From 1d1562e765dc17bd80e73a17f4c09826bc103263 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Sun, 19 Aug 2018 14:31:02 -0700 Subject: [PATCH 012/114] More Schedules Direct fixes --- internal/xmltv/xmltv.go | 60 ++++++--- lineup.go | 277 ++++++++++++++++++++++++++++++++-------- 2 files changed, 267 insertions(+), 70 deletions(-) diff --git a/internal/xmltv/xmltv.go b/internal/xmltv/xmltv.go index 37c70c2..1cf2ea6 100644 --- a/internal/xmltv/xmltv.go +++ b/internal/xmltv/xmltv.go @@ -3,6 +3,7 @@ package xmltv import ( "encoding/xml" + "fmt" "os" "time" @@ -33,24 +34,51 @@ func (t *Time) UnmarshalXMLAttr(attr xml.Attr) error { return nil } -type Date struct { - time.Time +type Date time.Time + +func (p Date) MarshalXML(e *xml.Encoder, start xml.StartElement) error { + t := time.Time(p) + if t.IsZero() { + return e.EncodeElement(nil, start) + } + return e.EncodeElement(t.Format("20060102"), start) } -func (c *Date) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { - var v string - if decodeErr := d.DecodeElement(&v, &start); decodeErr != nil { - return decodeErr +func (p *Date) UnmarshalXML(d *xml.Decoder, start xml.StartElement) (err error) { + var content string + if e := d.DecodeElement(&content, &start); e != nil { + return fmt.Errorf("get the type Date field of %s error", start.Name.Local) } - dateFormat := "20060102" // yyyymmdd date format - if len(v) == 4 { - dateFormat = "2006" - } - parse, err := time.ParseInLocation(dateFormat, v, time.UTC) - if err != nil { - return err + + dateFormat := "20060102" + + if len(content) == 4 { + dateFormat = "2006" + } + + if v, e := time.Parse(dateFormat, content); e != nil { + return fmt.Errorf("the type Date field of %s is not a time, value is: %s", start.Name.Local, content) + } else { + *p = Date(v) + } + return nil +} + +func (p Date) MarshalJSON() ([]byte, error) { + t := time.Time(p) + str := "\"" + t.Format("20060102") + "\"" + + return []byte(str), nil +} + +func (p *Date) UnmarshalJSON(text []byte) (err error) { + strDate := string(text[1 : 8+1]) + + if v, e := time.Parse("20060102", strDate); e != nil { + return fmt.Errorf("Date should be a time, error value is: %s", strDate) + } else { + *p = Date(v) } - *c = Date{parse} return nil } @@ -114,7 +142,7 @@ type Programme struct { PreviouslyShown *PreviouslyShown `xml:"previously-shown,omitempty" json:"previously_shown,omitempty"` Premiere *CommonElement `xml:"premiere,omitempty" json:"premiere,omitempty"` LastChance *CommonElement `xml:"last-chance,omitempty" json:"last_chance,omitempty"` - New ElementPresent `xml:"new>placeholder,omitempty" json:"new,omitempty"` + New ElementPresent `xml:"new" json:"new,omitempty"` Subtitles []Subtitle `xml:"subtitles,omitempty" json:"subtitles,omitempty"` Ratings []Rating `xml:"rating,omitempty" json:"ratings,omitempty"` StarRatings []Rating `xml:"star-rating,omitempty" json:"star_ratings,omitempty"` @@ -140,7 +168,7 @@ type ElementPresent bool // MarshalXML used to determine if the element is present or not. see https://stackoverflow.com/a/46516243 func (c *ElementPresent) MarshalXML(e *xml.Encoder, start xml.StartElement) error { - return e.EncodeElement(*c, start) + return e.EncodeElement(nil, start) } // UnmarshalXML used to determine if the element is present or not. see https://stackoverflow.com/a/46516243 diff --git a/lineup.go b/lineup.go index 168f055..1d27dd7 100644 --- a/lineup.go +++ b/lineup.go @@ -12,8 +12,8 @@ import ( "strconv" "strings" - "github.com/nathanjjohnson/GoSchedulesDirect" "github.com/spf13/viper" + "github.com/tellytv/go.schedulesdirect" m3u "github.com/tellytv/telly/internal/m3uplus" "github.com/tellytv/telly/internal/providers" "github.com/tellytv/telly/internal/xmltv" @@ -23,6 +23,7 @@ import ( // var callSignRegex = regexp.MustCompile(`^[A-Z0-9]+$`).MatchString // var hdRegex = regexp.MustCompile(`hd|4k`) var xmlNSRegex = regexp.MustCompile(`(\d).(\d).(?:(\d)/(\d))?`) +var ddProgIDRegex = regexp.MustCompile(`(?m)(EP|SH|MV|SP)(\d{7,8}).(\d+).?(?:(\d).(\d))?`) // hdHomeRunLineupItem is a HDHomeRun specification compatible representation of a Track available in the lineup. type hdHomeRunLineupItem struct { @@ -67,7 +68,7 @@ type lineup struct { channels map[int]hdHomeRunLineupItem - sd *GoSchedulesDirect.Client + sd *schedulesdirect.Client } // newLineup returns a new lineup for the given config struct. @@ -99,19 +100,13 @@ func newLineup() *lineup { channels: make(map[int]hdHomeRunLineupItem), } - sdClient := GoSchedulesDirect.NewClient(viper.GetString("schedulesdirect.username"), viper.GetString("schedulesdirect.password")) - - // FIXME: Check that SD is online before continuing. - - status, statusErr := sdClient.GetStatus() - if statusErr != nil { - panic(statusErr) + sdClient, sdClientErr := schedulesdirect.NewClient(viper.GetString("schedulesdirect.username"), viper.GetString("schedulesdirect.password")) + if sdClientErr != nil { + log.WithError(sdClientErr).Panicln("error setting up schedules direct client") } lineup.sd = sdClient - log.Infof("SD status %+v", status) - for _, cfg := range cfgs { provider, providerErr := cfg.GetProvider() if providerErr != nil { @@ -153,6 +148,7 @@ func (l *lineup) processProvider(provider providers.Provider) (int, error) { m3u, channelMap, programmeMap, prepareErr := l.prepareProvider(provider) if prepareErr != nil { log.WithError(prepareErr).Errorln("error when preparing provider") + return 0, prepareErr } if provider.Configuration().SortKey != "" { @@ -306,6 +302,8 @@ func (l *lineup) prepareEPG(provider providers.Provider, cacheFiles bool) (map[s return epgChannelMap, epgProgrammeMap, epgErr } + augmentWithSD := viper.IsSet("schedulesdirect.username") && viper.IsSet("schedulesdirect.password") + sdEligible := make(map[string]xmltv.Programme) // TMSID:programme haveAllInfo := make(map[string][]xmltv.Programme) // channel number:[]programme @@ -314,54 +312,104 @@ func (l *lineup) prepareEPG(provider providers.Provider, cacheFiles bool) (map[s for _, programme := range epg.Programmes { if programme.Channel == channel.ID { - epgProgrammeMap[channel.ID] = append(epgProgrammeMap[channel.ID], *provider.ProcessProgramme(programme)) ddProgID := "" - if viper.IsSet("schedulesdirect.username") && viper.IsSet("schedulesdirect.password") { + if augmentWithSD { for _, epNum := range programme.EpisodeNums { if epNum.System == "dd_progid" { ddProgID = epNum.Value } } } - if ddProgID != "" { - sdEligible[ddProgID] = programme + if augmentWithSD == true && ddProgID != "" { + idType, uniqID, epID, _, _, extractErr := extractDDProgID(ddProgID) + if extractErr != nil { + log.WithError(extractErr).Errorln("error extracting dd_progid") + continue + } + cleanID := fmt.Sprintf("%s%s%s", idType, padNumberWithZero(uniqID, 8), padNumberWithZero(epID, 4)) + if len(cleanID) < 14 { + log.Warnf("found an invalid TMS ID/dd_progid, expected length of exactly 14, got %d: %s\n", len(cleanID), cleanID) + continue + } + + sdEligible[cleanID] = programme } else { - haveAllInfo[channel.ID] = append(haveAllInfo[channel.ID], *provider.ProcessProgramme(programme)) + haveAllInfo[channel.ID] = append(haveAllInfo[channel.ID], programme) } } } } - tmsIDs := make([]string, 0) + if augmentWithSD { + tmsIDs := make([]string, 0) - for tmsID := range sdEligible { - cleanID := strings.Replace(tmsID, ".", "", -1) - if len(cleanID) < 14 { - log.Warnf("found an invalid TMS ID/dd_progid: %s", cleanID) - continue + for tmsID := range sdEligible { + idType, uniqID, epID, _, _, extractErr := extractDDProgID(tmsID) + if extractErr != nil { + log.WithError(extractErr).Errorln("error extracting dd_progid") + continue + } + cleanID := fmt.Sprintf("%s%s%s", idType, padNumberWithZero(uniqID, 8), padNumberWithZero(epID, 4)) + if len(cleanID) < 14 { + log.Warnf("found an invalid TMS ID/dd_progid, expected length of exactly 14, got %d: %s\n", len(cleanID), cleanID) + continue + } + tmsIDs = append(tmsIDs, cleanID) } - tmsIDs = append(tmsIDs, cleanID[0:13]) - } - log.Infof("Requesting guide data for %d programs from Schedules Direct", len(tmsIDs)) + log.Infof("Requesting guide data for %d programs from Schedules Direct", len(tmsIDs)) + + allResponses := make([]schedulesdirect.ProgramInfo, 0) + + artworkMap := make(map[string][]schedulesdirect.ProgramArtwork) - allResponses := make([]GoSchedulesDirect.ProgramInfo, len(tmsIDs)) + chunks := chunkStringSlice(tmsIDs, 5000) - for _, chunk := range chunkStringSlice(tmsIDs, 5000) { - moreInfo, moreInfoErr := l.sd.GetProgramInfo(chunk) - if moreInfoErr != nil { - log.WithError(moreInfoErr).Errorln("Error when getting more program details from Schedules Direct") - return epgChannelMap, epgProgrammeMap, moreInfoErr + log.Infof("Making %d requests to Schedules Direct for program information, this might take a while", len(chunks)) + + for _, chunk := range chunks { + moreInfo, moreInfoErr := l.sd.GetProgramInfo(chunk) + if moreInfoErr != nil { + log.WithError(moreInfoErr).Errorln("Error when getting more program details from Schedules Direct") + return epgChannelMap, epgProgrammeMap, moreInfoErr + } + + log.Debugf("received %d responses for chunk", len(moreInfo)) + + allResponses = append(allResponses, moreInfo...) } - allResponses = append(allResponses, moreInfo...) - } + artworkTMSIDs := make([]string, 0) - log.Infoln("Got %d responses from SD", len(allResponses)) + for _, entry := range allResponses { + if entry.HasArtwork() { + artworkTMSIDs = append(artworkTMSIDs, entry.ProgramID) + } + } + + chunks = chunkStringSlice(artworkTMSIDs, 500) + + log.Infof("Making %d requests to Schedules Direct for artwork, this might take a while", len(chunks)) + + for _, chunk := range chunks { + artwork, artworkErr := l.sd.GetArtworkForProgramIDs(chunk) + if artworkErr != nil { + log.WithError(artworkErr).Errorln("Error when getting program artwork from Schedules Direct") + return epgChannelMap, epgProgrammeMap, artworkErr + } - for _, sdResponse := range allResponses { - mergedProgramme := MergeSchedulesDirectAndXMLTVProgramme(sdEligible[sdResponse.ProgramID], sdResponse) - haveAllInfo[mergedProgramme.Channel] = append(haveAllInfo[mergedProgramme.Channel], mergedProgramme) + for _, artworks := range artwork { + artworkMap[artworks.ProgramID] = append(artworkMap[artworks.ProgramID], *artworks.Artwork...) + } + } + + log.Debugf("Got %d responses from SD", len(allResponses)) + + for _, sdResponse := range allResponses { + programme := sdEligible[sdResponse.ProgramID] + mergedProgramme := MergeSchedulesDirectAndXMLTVProgramme(&programme, sdResponse, artworkMap[sdResponse.ProgramID]) + haveAllInfo[mergedProgramme.Channel] = append(haveAllInfo[mergedProgramme.Channel], *mergedProgramme) + } } for _, programmes := range haveAllInfo { @@ -479,7 +527,7 @@ func chunkStringSlice(sl []string, chunkSize int) [][]string { return divided } -func MergeSchedulesDirectAndXMLTVProgramme(programme xmltv.Programme, sdProgram GoSchedulesDirect.ProgramInfo) xmltv.Programme { +func MergeSchedulesDirectAndXMLTVProgramme(programme *xmltv.Programme, sdProgram schedulesdirect.ProgramInfo, artworks []schedulesdirect.ProgramArtwork) *xmltv.Programme { allTitles := make([]string, 0) @@ -501,13 +549,16 @@ func MergeSchedulesDirectAndXMLTVProgramme(programme xmltv.Programme, sdProgram allKeywords = append(allKeywords, keyword.Value) } - for keywordType, keywords := range sdProgram.Keywords { - log.Infoln("Adding keywords category", keywordType) + for _, keywords := range sdProgram.Keywords { for _, keyword := range keywords { allKeywords = append(allKeywords, keyword) } } + for _, keyword := range UniqueStrings(allKeywords) { + programme.Keywords = append(programme.Keywords, xmltv.CommonElement{Value: keyword}) + } + // FIXME: We should really be making sure that we passthrough languages. allDescriptions := make([]string, 0) @@ -517,7 +568,12 @@ func MergeSchedulesDirectAndXMLTVProgramme(programme xmltv.Programme, sdProgram for _, descriptions := range sdProgram.Descriptions { for _, description := range descriptions { - allDescriptions = append(allDescriptions, description.Description) + if description.Description100 != "" { + allDescriptions = append(allDescriptions, description.Description100) + } + if description.Description1000 != "" { + allDescriptions = append(allDescriptions, description.Description1000) + } } } @@ -525,10 +581,6 @@ func MergeSchedulesDirectAndXMLTVProgramme(programme xmltv.Programme, sdProgram programme.Descriptions = append(programme.Descriptions, xmltv.CommonElement{Value: description}) } - for _, keyword := range UniqueStrings(allKeywords) { - programme.Keywords = append(programme.Keywords, xmltv.CommonElement{Value: keyword}) - } - allRatings := make(map[string]string, 0) for _, rating := range programme.Ratings { @@ -543,36 +595,83 @@ func MergeSchedulesDirectAndXMLTVProgramme(programme xmltv.Programme, sdProgram programme.Ratings = append(programme.Ratings, xmltv.Rating{Value: rating, System: system}) } + for _, artwork := range artworks { + programme.Icons = append(programme.Icons, xmltv.Icon{ + Source: getImageURL(artwork.URI), + Width: artwork.Width, + Height: artwork.Height, + }) + } + hasXMLTVNS := false + ddProgID := "" for _, epNum := range programme.EpisodeNums { if epNum.System == "xmltv_ns" { hasXMLTVNS = true + } else if epNum.System == "dd_progid" { + ddProgID = epNum.Value } } if !hasXMLTVNS { - seasonNumber := 0 - episodeNumber := 0 + seasonNumber := int64(0) + episodeNumber := int64(0) + totalSeasons := int64(0) + totalEpisodes := int64(0) numbersFilled := false for _, meta := range sdProgram.Metadata { for _, metadata := range meta { - if metadata.Season != nil { - seasonNumber = *metadata.Season - 1 // SD metadata isnt 0 index + if metadata.Season > 0 { + seasonNumber = metadata.Season - 1 // SD metadata isnt 0 index + numbersFilled = true + } + if metadata.Episode > 0 { + episodeNumber = metadata.Episode - 1 + numbersFilled = true + } + if metadata.TotalEpisodes > 0 { + totalEpisodes = metadata.TotalEpisodes numbersFilled = true } - if metadata.Episode != nil { - episodeNumber = *metadata.Episode - 1 + if metadata.TotalSeasons > 0 { + totalSeasons = metadata.TotalSeasons numbersFilled = true } } } if numbersFilled { - // FIXME: There is currently no way to determine multipart episodes from SD. - // We could use the dd_progid to determine it though. - xmlTVNS := fmt.Sprintf("%d.%d.0/1", seasonNumber, episodeNumber) + seasonNumberStr := fmt.Sprintf("%d", seasonNumber) + if totalSeasons > 0 { + seasonNumberStr = fmt.Sprintf("%d/%d", seasonNumber, totalSeasons) + } + episodeNumberStr := fmt.Sprintf("%d", episodeNumber) + if totalEpisodes > 0 { + episodeNumberStr = fmt.Sprintf("%d/%d", episodeNumber, totalEpisodes) + } + + partNumber := 0 + totalParts := 0 + + if ddProgID != "" { + var extractErr error + _, _, _, partNumber, totalParts, extractErr = extractDDProgID(ddProgID) + if extractErr != nil { + panic(extractErr) + } + } + + partStr := "0" + if partNumber > 0 { + partStr = fmt.Sprintf("%d", partNumber) + if totalParts > 0 { + partStr = fmt.Sprintf("%d/%d", partNumber, totalParts) + } + } + + xmlTVNS := fmt.Sprintf("%s.%s.%s", seasonNumberStr, episodeNumberStr, partStr) programme.EpisodeNums = append(programme.EpisodeNums, xmltv.EpisodeNum{System: "xmltv_ns", Value: xmlTVNS}) } } @@ -635,6 +734,48 @@ func extractXMLTVNS(str string) (int, int, int, int, error) { return season, episode, currentPartNum, totalPartsNum, nil } +// extractDDProgID returns type, ID, episode ID, part number, total parts, error. +func extractDDProgID(progID string) (string, int, int, int, int, error) { + matches := ddProgIDRegex.FindAllStringSubmatch(progID, -1) + + if len(matches) == 0 { + return "", 0, 0, 0, 0, fmt.Errorf("invalid dd_progid: %s", progID) + } + + itemType := matches[0][1] + + itemID, itemIDErr := strconv.Atoi(matches[0][2]) + if itemIDErr != nil { + return itemType, 0, 0, 0, 0, itemIDErr + } + + specificID, specificIDErr := strconv.Atoi(matches[0][3]) + if specificIDErr != nil { + return itemType, itemID, 0, 0, 0, specificIDErr + } + + currentPartNum := 0 + totalPartsNum := 0 + + if len(matches[0]) > 2 && matches[0][4] != "" { + currentPart, currentPartErr := strconv.Atoi(matches[0][4]) + if currentPartErr != nil { + return itemType, itemID, specificID, 0, 0, currentPartErr + } + currentPartNum = currentPart + } + + if len(matches[0]) > 3 && matches[0][5] != "" { + totalParts, totalPartsErr := strconv.Atoi(matches[0][5]) + if totalPartsErr != nil { + return itemType, itemID, specificID, currentPartNum, 0, totalPartsErr + } + totalPartsNum = totalParts + } + + return itemType, itemID, specificID, currentPartNum, totalPartsNum, nil +} + func UniqueStrings(input []string) []string { u := make([]string, 0, len(input)) m := make(map[string]bool) @@ -648,3 +789,31 @@ func UniqueStrings(input []string) []string { return u } + +func getImageURL(imageURI string) string { + if strings.HasPrefix(imageURI, "https://s3.amazonaws.com") { + return imageURI + } + return fmt.Sprint(schedulesdirect.DefaultBaseURL, schedulesdirect.APIVersion, "/image/", imageURI) +} + +func padNumberWithZero(value int, expectedLength int) string { + padded := fmt.Sprintf("%02d", value) + valLength := countDigits(value) + if valLength != expectedLength { + return fmt.Sprintf("%s%d", strings.Repeat("0", expectedLength-valLength), value) + } + return padded +} + +func countDigits(i int) int { + count := 0 + if i == 0 { + count = 1 + } + for i != 0 { + i /= 10 + count = count + 1 + } + return count +} From f41fff148dd675acb2e7c7d98a9171ea18ffabac Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Sun, 19 Aug 2018 14:44:59 -0700 Subject: [PATCH 013/114] Update README --- README.md | 97 ++++++++++++++++++++++++++++++++----------------------- 1 file changed, 57 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 56f2112..f4197b9 100644 --- a/README.md +++ b/README.md @@ -2,36 +2,61 @@ IPTV proxy for Plex Live written in Golang -# Setup - -> **See end of setup section for an important note about channel filtering** - -1) Go to the releases page and download the correct version for your Operating System -2) Mark the file as executable for non-windows platforms `chmod a+x ` -3) Rename the file to "telly" if desired; note that from here this readme will refer to "telly"; the file you downloaded is probably called "telly-linux-amd64.dms" or something like that. -**If you do not rename the file, then substitute references here to "telly" with the name of the file you've downloaded.** -**Under Windows, don't forget the `.exe`; i.e. `telly.exe`.** -4) Have the .m3u file on hand from your IPTV provider of choice -**Any command arguments can also be supplied as environment variables, for example --iptv.playlist can also be provided as the TELLY_IPTV_PLAYLIST environment variable** -5) Run `telly` with the `--iptv.playlist` commandline argument pointing to your .m3u file. (This can be a local file or a URL) For example: `./telly --iptv.playlist=/home/github/myiptv.m3u` -6) If you would like multiple streams/tuners use the `--iptv.streams` commandline option. Default is 1. When setting or changing this option, `plexmediaserver` will need to be completely **restarted**. -7) If you would like `telly` to attempt to the filter the m3u a bit, add the `--filter.regex` commandline option. If you would like to use your own regex, run `telly` with `--filter.regex=""`, for example `--filter.regex=".*UK.*"` -8) If `telly` tells you `[telly] [info] listening on ...` - great! Your .m3u file was successfully parsed and `telly` is running. Check below for how to add it into Plex. -9) If `telly` fails to run, check the error. If it's self explanatory, great. If you don't understand, feel free to open an issue and we'll help you out. As of telly v0.4 `sed` commands are no longer needed. Woop! -10) For your IPTV provider m3u, try using option `type=m3u_plus` and `output=ts`. - -> **Regex handling changed in 1.0. `filter.regex` has become blacklist which defaults to blocking everything. If you are not using a regex to filter your M3U file, you will need to add at a minimum `--regex.inclusive=true` to the command line. If you do not add this, telly will by default EXCLUDE everything in your M3U. The symptom here is typically telly seeming to start up just fine but reporting 0 channels.** - -# Adding it into Plex - -1) Once `telly` is running, you can add it to Plex. **Plex Live requires Plex Pass at the time of writing** -2) Navigate to `app.plex.tv` and make sure you're logged in. Go to Settings -> Server -> Live TV & DVR -3) Click 'Setup' or 'Add'. Plex won't find your device, so press the text to add it manually - input `localhost:6077` (or whatever port you're using - you can change it using the `-listen` commandline argument, i.e. `-listen localhost:12345`) -4) Plex will find your device (in some cases it continues to load but the continue button becomes orange, i.e. clickable. Click it) - select the country in the bottom left and ensure Plex has found the channels. Proceed. -5) Once you get to the channel listing, `telly` currently __doesn't__ have any idea of EPG data so it __starts the channel numbers at 10000 to avoid complications__ with selecting channels at this stage. EPG APIs will come in the future, but for now you'll have to manually match up what `telly` is telling Plex to the actual channel numbers. For UK folk, `Sky HD` is the best option I've found. -6) Once you've matched up all the channels, hit next and Plex will start downloading necessary EPG data. -7) Once that is done, you might need to restart Plex so the telly tuner is not marked as dead. -8) You're done! Enjoy using `telly`. :-) +# Configuration + +Here's an example configuration file. It should be placed in `/etc/telly/telly.config.toml` or `$HOME/.telly/telly.config.toml` or `telly.config.toml` in the directory that telly is running from. + +```toml +[Discovery] + Device-Auth = "telly123" + Device-ID = 12345678 + Device-UUID = "" + Device-Firmware-Name = "hdhomeruntc_atsc" + Device-Firmware-Version = "20150826" + Device-Friendly-Name = "telly" + Device-Manufacturer = "Silicondust" + Device-Model-Number = "HDTC-2US" + SSDP = true + +[IPTV] + Streams = 1 + Starting-Channel = 10000 + XMLTV-Channels = true + +[Log] + Level = "info" + Requests = true + +[Web] + Base-Address = "0.0.0.0:6077" + Listen-Address = "0.0.0.0:6077" + +[SchedulesDirect] + Username = "" + Password = "" + +[[Source]] + Name = "" + Provider = "Vaders" + Username = "" + Password = "" + Filter = "Sports|Premium Movies|United States.*|USA" + FilterKey = "tvg-name" // FilterKey normally defaults to whatever the provider file says is best, otherwise you must set this. + FilterRaw = false // FilterRaw will run your regex on the entire line instead of just specific keys. + Sort = "group-title" // Sort will alphabetically sort your channels by the M3U key provided + +[[Source]] + Name = "" + Provider = "IPTV-EPG" + Username = "M3U-Identifier" + Password = "XML-Identifier" + + +[[Source]] + Provider = "Custom" + M3U = "http://myprovider.com/playlist.m3u" + EPG = "http://myprovider.com/epg.xml" +``` # Docker @@ -41,9 +66,7 @@ docker run -d \ --name='telly' \ --net='bridge' \ -e TZ="Europe/Amsterdam" \ - -e 'TELLY_IPTV_PLAYLIST'='/home/github/myiptv.m3u' \ - -e TELLY_IPTV_STREAMS=1 \ - -e TELLY_FILTER_REGEX='.*UK.*' \ + -e 'TELLY_CONFIG_FILE'='/telly.config.toml' \ -p '6077:6077/tcp' \ -v '/tmp/telly':'/tmp':'rw' \ tellytv/telly --listen.base-address=localhost:6077 @@ -57,17 +80,11 @@ telly: - "6077:6077" environment: - TZ=Europe/Amsterdam - - TELLY_IPTV_PLAYLIST=/home/github/myiptv.m3u - - TELLY_FILTER_REGEX='.*UK.*' - - TELLY_WEB_LISTEN_ADDRESS=telly:6077 - - TELLY_IPTV_STREAMS=1 - - TELLY_DISCOVERY_FRIENDLYNAME=Tuner1 - - TELLY_DISCOVERY_DEVICEID=12345678 + - TELLY_CONFIG_FILE=/telly.config.toml command: -base=telly:6077 restart: unless-stopped ``` - # Troubleshooting Please free to open an issue if you run into any issues at all, I'll be more than happy to help. From 0c8d54609233f1cd7296acd4f11bb6657dd92d8b Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Sun, 19 Aug 2018 14:47:53 -0700 Subject: [PATCH 014/114] Update Gopkg.lock --- Gopkg.lock | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Gopkg.lock b/Gopkg.lock index 3209f48..8499add 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -223,6 +223,14 @@ revision = "907c19d40d9a6c9bb55f040ff4ae45271a4754b9" version = "v1.1.0" +[[projects]] + branch = "master" + digest = "1:2f6be3c7ff8cc65d5f6b35c2acd928aed1386fc31dc11483045b393660698244" + name = "github.com/tellytv/go.schedulesdirect" + packages = ["."] + pruneopts = "UT" + revision = "3d6704d3b108deaffd476ad2f27003dc38bf775d" + [[projects]] digest = "1:c268acaa4a4d94a467980e5e91452eb61c460145765293dc0aed48e5e9919cc6" name = "github.com/ugorji/go" @@ -323,6 +331,7 @@ "github.com/sirupsen/logrus", "github.com/spf13/pflag", "github.com/spf13/viper", + "github.com/tellytv/go.schedulesdirect", "golang.org/x/net/html/charset", ] solver-name = "gps-cdcl" From 02c02065a07adb8c02450804c47e9195fe6e9998 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Sun, 19 Aug 2018 15:16:48 -0700 Subject: [PATCH 015/114] Fix XMLTV tests --- internal/xmltv/xmltv_test.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/xmltv/xmltv_test.go b/internal/xmltv/xmltv_test.go index f2eec7c..4823566 100644 --- a/internal/xmltv/xmltv_test.go +++ b/internal/xmltv/xmltv_test.go @@ -60,7 +60,7 @@ func TestDecode(t *testing.T) { }, Icons: []Icon{ Icon{ - Source: `file://C:\Perl\site/share/internal/xmltv/icons/KERA.gif`, + Source: `file://C:\Perl\site/share/xmltv/icons/KERA.gif`, }, }, } @@ -69,9 +69,10 @@ func TestDecode(t *testing.T) { } loc := time.FixedZone("", -6*60*60) + date := time.Date(2008, 07, 11, 0, 0, 0, 0, time.UTC) pr := Programme{ ID: "someId", - Date: "20080711", + Date: Date(date), Channel: "I10436.labs.zap2it.com", Start: &Time{time.Date(2008, 07, 15, 0, 30, 0, 0, loc)}, Stop: &Time{time.Date(2008, 07, 15, 1, 0, 0, 0, loc)}, From 7b34d39314f21a4b496895a3b6222f1a5b49fe23 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Sun, 19 Aug 2018 15:29:52 -0700 Subject: [PATCH 016/114] Fix example.xml path --- internal/xmltv/xmltv_test.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/internal/xmltv/xmltv_test.go b/internal/xmltv/xmltv_test.go index 4823566..b3767d5 100644 --- a/internal/xmltv/xmltv_test.go +++ b/internal/xmltv/xmltv_test.go @@ -17,9 +17,14 @@ func dummyReader(charset string, input io.Reader) (io.Reader, error) { } func TestDecode(t *testing.T) { + dir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + // Example downloaded from http://wiki.xmltv.org/index.php/internal/xmltvFormat // One may check it with `xmllint --noout --dtdvalid xmltv.dtd example.xml` - f, err := os.Open("example.xml") + f, err := os.Open(fmt.Sprintf("%s/example.xml", dir)) if err != nil { t.Fatal(err) } From 2c9f0bd3c74ecfe1b5fd6ece78255037174ec920 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Sun, 19 Aug 2018 15:30:02 -0700 Subject: [PATCH 017/114] Update README.md --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index f4197b9..4969aaf 100644 --- a/README.md +++ b/README.md @@ -41,9 +41,9 @@ Here's an example configuration file. It should be placed in `/etc/telly/telly.c Username = "" Password = "" Filter = "Sports|Premium Movies|United States.*|USA" - FilterKey = "tvg-name" // FilterKey normally defaults to whatever the provider file says is best, otherwise you must set this. - FilterRaw = false // FilterRaw will run your regex on the entire line instead of just specific keys. - Sort = "group-title" // Sort will alphabetically sort your channels by the M3U key provided + FilterKey = "tvg-name" # FilterKey normally defaults to whatever the provider file says is best, otherwise you must set this. + FilterRaw = false # FilterRaw will run your regex on the entire line instead of just specific keys. + Sort = "group-title" # Sort will alphabetically sort your channels by the M3U key provided [[Source]] Name = "" From 39768616c05f3c3aa0d63a175dfb53ae4c27cda7 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Sun, 19 Aug 2018 15:32:02 -0700 Subject: [PATCH 018/114] Allow xmltv/example.xml into Git --- .gitignore | 2 +- internal/xmltv/example.xml | 182 +++++++++++++++++++++++++++++++++++++ 2 files changed, 183 insertions(+), 1 deletion(-) create mode 100644 internal/xmltv/example.xml diff --git a/.gitignore b/.gitignore index 05fc00e..9143f10 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,7 @@ telly .DS_Store /.GOPATH /bin -*.xml +/*.xml vendor/ /.build /.release diff --git a/internal/xmltv/example.xml b/internal/xmltv/example.xml new file mode 100644 index 0000000..f71df21 --- /dev/null +++ b/internal/xmltv/example.xml @@ -0,0 +1,182 @@ + + + + + + 13 KERA + 13 KERA TX42822:- + 13 + 13 KERA fcc + KERA + KERA + PBS Affiliate + + + + 11 KTVT + 11 KTVT TX42822:- + 11 + 11 KTVT fcc + KTVT + KTVT + CBS Affiliate + + + + NOW on PBS + Jordan's Queen Rania has made job creation a priority to help curb the staggering unemployment rates among youths in the Middle East. + 20080711 + Newsmagazine + Interview + Public affairs + Series + EP01006886.0028 + 427 + + + + + + Mystery! + Foyle's War, Series IV: Bleak Midwinter + Foyle investigates an explosion at a munitions factory, which he comes to believe may have been premeditated. + 20070701 + Anthology + Mystery + Series + EP00003026.0665 + 2705 + + + + + + Mystery! + Foyle's War, Series IV: Casualties of War + The murder of a prominent scientist may have been due to a gambling debt. + 20070708 + Anthology + Mystery + Series + EP00003026.0666 + 2706 + + + + + + BBC World News + International issues. + News + Series + SH00315789.0000 + + + + + Sit and Be Fit + 20070924 + Exercise + Series + EP00003847.0074 + 901 + + + + + + The Early Show + Republican candidate John McCain; premiere of the film "The Dark Knight." + 20080715 + Talk + News + Series + EP00337003.2361 + + + + + Rachael Ray + Actresses Kim Raver, Brooke Shields and Lindsay Price ("Lipstick Jungle"); women in their 40s tell why they got breast implants; a 30-minute meal. + + Rachael Ray + + 20080306 + Talk + Series + EP00847333.0303 + 2119 + + + + + + The Price Is Right + Contestants bid for prizes then compete for fabulous showcases. + + Bart Eskander + Roger Dobkowitz + Drew Carey + + Game show + Series + SH00004372.0000 + + + + TV-G + + + + Jeopardy! + + Alex Trebek + + 20080715 + Game show + Series + EP00002348.1700 + 5507 + + + TV-G + + + + The Young and the Restless + Sabrina Offers Victoria a Truce + Jeff thinks Kyon stole the face cream; Nikki asks Nick to give David a chance; Amber begs Adrian to go to Australia. + + Peter Bergman + Eric Braeden + Jeanne Cooper + Melody Thomas Scott + + 20080715 + Soap + Series + EP00004422.1359 + 8937 + + + + TV-14 + + + From 66fb27756e99c5f9bda28ffa1ecf8b7858a17a12 Mon Sep 17 00:00:00 2001 From: Guy Spronck Date: Mon, 20 Aug 2018 20:19:48 +0200 Subject: [PATCH 019/114] Add option to ignore-epg-icons under misc --- lineup.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lineup.go b/lineup.go index 1d27dd7..224405a 100644 --- a/lineup.go +++ b/lineup.go @@ -248,6 +248,9 @@ func (l *lineup) processProviderChannel(channel *providers.ProviderChannel, prog } if channel.Logo != "" && channel.EPGChannel != nil && !containsIcon(channel.EPGChannel.Icons, channel.Logo) { + if viper.GetBool("misc.ignore-epg-icons") { + channel.EPGChannel.Icons = nil + } channel.EPGChannel.Icons = append(channel.EPGChannel.Icons, xmltv.Icon{Source: channel.Logo}) } From 309792d7b5976a9689d2386c43961ead77cc11ec Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Mon, 20 Aug 2018 11:31:06 -0700 Subject: [PATCH 020/114] Only initialize Schedules Direct if configured. Fixes #149 --- VERSION | 2 +- lineup.go | 12 +++++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/VERSION b/VERSION index 9084fa2..238afc2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.0 +1.1.0.1 diff --git a/lineup.go b/lineup.go index 224405a..ecad8fd 100644 --- a/lineup.go +++ b/lineup.go @@ -100,12 +100,14 @@ func newLineup() *lineup { channels: make(map[int]hdHomeRunLineupItem), } - sdClient, sdClientErr := schedulesdirect.NewClient(viper.GetString("schedulesdirect.username"), viper.GetString("schedulesdirect.password")) - if sdClientErr != nil { - log.WithError(sdClientErr).Panicln("error setting up schedules direct client") - } + if viper.IsSet("schedulesdirect.username") && viper.IsSet("schedulesdirect.password") { + sdClient, sdClientErr := schedulesdirect.NewClient(viper.GetString("schedulesdirect.username"), viper.GetString("schedulesdirect.password")) + if sdClientErr != nil { + log.WithError(sdClientErr).Panicln("error setting up schedules direct client") + } - lineup.sd = sdClient + lineup.sd = sdClient + } for _, cfg := range cfgs { provider, providerErr := cfg.GetProvider() From 107d30a0e53372dfda340bd5c555a93bb1443a4b Mon Sep 17 00:00:00 2001 From: Chaz Larson Date: Mon, 20 Aug 2018 22:08:36 -0400 Subject: [PATCH 021/114] FAQs Add remarks to address questions asked repeatedly on discord. --- README.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 4969aaf..27f0233 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ IPTV proxy for Plex Live written in Golang # Configuration -Here's an example configuration file. It should be placed in `/etc/telly/telly.config.toml` or `$HOME/.telly/telly.config.toml` or `telly.config.toml` in the directory that telly is running from. +Here's an example configuration file. You will need to create this file. It should be placed in `/etc/telly/telly.config.toml` or `$HOME/.telly/telly.config.toml` or `telly.config.toml` in the directory that telly is running from. ```toml [Discovery] @@ -57,6 +57,11 @@ Here's an example configuration file. It should be placed in `/etc/telly/telly.c M3U = "http://myprovider.com/playlist.m3u" EPG = "http://myprovider.com/epg.xml" ``` +You only need one source; the ones you are not using should be commented out or deleted. The filter-related keys can be used with any of the sources. + +If you do not have a Schedules Direct account, that section can be removed or left blank. + +Set listen- and base-address to the IP address of the machine running telly. # Docker From a2fd7425ff1d95632b5cc2d543236243c7309916 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Mon, 20 Aug 2018 20:50:37 -0700 Subject: [PATCH 022/114] Fixes for bad providers & gzip --- lineup.go | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/lineup.go b/lineup.go index ecad8fd..94de56f 100644 --- a/lineup.go +++ b/lineup.go @@ -467,12 +467,22 @@ func getFile(path string, cacheFiles bool) (io.ReadCloser, string, error) { if strings.HasPrefix(strings.ToLower(path), "http") { + transport = "http" + + req, reqErr := http.NewRequest("GET", path, nil) + if reqErr != nil { + return nil, transport, reqErr + } + + // For whatever reason, some providers only allow access from a "real" User-Agent. + req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/68.0.3440.106 Safari/537.36") + resp, err := http.Get(path) if err != nil { return nil, transport, err } - if strings.HasSuffix(strings.ToLower(path), ".gz") { + if strings.HasSuffix(strings.ToLower(path), ".gz") || resp.Header.Get("Content-Type") == "application/x-gzip" { log.Infof("File (%s) is gzipp'ed, ungzipping now, this might take a while", path) gz, gzErr := gzip.NewReader(resp.Body) if gzErr != nil { From e74ee7648e20afcbff16451fc1996c69a25fae5c Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Mon, 20 Aug 2018 20:50:58 -0700 Subject: [PATCH 023/114] Temporary fix for artwork nil issues --- lineup.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lineup.go b/lineup.go index 94de56f..fca645f 100644 --- a/lineup.go +++ b/lineup.go @@ -404,6 +404,9 @@ func (l *lineup) prepareEPG(provider providers.Provider, cacheFiles bool) (map[s } for _, artworks := range artwork { + if artworks.ProgramID == "" || artworks.Artwork == nil { + continue + } artworkMap[artworks.ProgramID] = append(artworkMap[artworks.ProgramID], *artworks.Artwork...) } } From b407718e4497e1d282ed0465e92ce82d4e776ea9 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Mon, 20 Aug 2018 21:10:19 -0700 Subject: [PATCH 024/114] Change username/password replacement tokens --- main.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/main.go b/main.go index 66fb61f..2e4d6f2 100644 --- a/main.go +++ b/main.go @@ -40,9 +40,9 @@ var ( stringSafer = func(input string) string { ret := input if strings.HasPrefix(input, "username=") { - ret = "username=hunter1" + ret = "username=REDACTED" } else if strings.HasPrefix(input, "password=") { - ret = "password=hunter2" + ret = "password=REDACTED" } else if strings.HasPrefix(input, "token=") { ret = "token=bm90Zm9yeW91" // "notforyou" } From 8f377617396664774b9f1496194c425270f87bca Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Mon, 20 Aug 2018 21:10:37 -0700 Subject: [PATCH 025/114] Dont panic on empty tags --- lineup.go | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/lineup.go b/lineup.go index fca645f..05f0d7a 100644 --- a/lineup.go +++ b/lineup.go @@ -279,15 +279,12 @@ func (l *lineup) FilterTrack(provider providers.Provider, track m3u.Track) bool filterKey := provider.RegexKey() if config.FilterKey != "" { - if key, ok := track.Tags[config.FilterKey]; key != "" && ok { - filterKey = config.FilterKey - } else { - log.Panicf("the provided filter key (%s) does not exist or is blank", config.FilterKey) - } + filterKey = config.FilterKey } - if _, ok := track.Tags[filterKey]; !ok { - log.Panicf("Provided filter key %s doesn't exist in M3U tags", filterKey) + if key, ok := track.Tags[filterKey]; key != "" && !ok { + log.Warnf("the provided filter key (%s) does not exist or is blank, skipping track: %s", config.FilterKey, track.Raw) + return false } log.Debugf("Checking if filter (%s) matches string %s", config.Filter, track.Tags[filterKey]) From 9f22c3a665dfc8f93059ed05242e6adb121b28de Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Mon, 20 Aug 2018 21:25:27 -0700 Subject: [PATCH 026/114] Add Iris as a supported provider --- internal/providers/iris.go | 77 ++++++++++++++++++++++++++++++++++++++ internal/providers/main.go | 2 + 2 files changed, 79 insertions(+) diff --git a/internal/providers/iris.go b/internal/providers/iris.go index 01d10ec..c05814a 100644 --- a/internal/providers/iris.go +++ b/internal/providers/iris.go @@ -1,4 +1,81 @@ package providers +import ( + "fmt" + "strings" + + m3u "github.com/tellytv/telly/internal/m3uplus" + "github.com/tellytv/telly/internal/xmltv" +) + // http://irislinks.net:83/get.php?username=username&password=password&type=m3uplus&output=ts // http://irislinks.net:83/xmltv.php?username=username&password=password + +type iris struct { + BaseConfig Configuration +} + +func newIris(config *Configuration) (Provider, error) { + return &iris{*config}, nil +} + +func (i *iris) Name() string { + return "Iris" +} + +func (i *iris) PlaylistURL() string { + return fmt.Sprintf("http://irislinks.net:83/get.php?username=%s&password=%s&type=m3u_plus&output=ts", i.BaseConfig.Username, i.BaseConfig.Password) +} + +func (i *iris) EPGURL() string { + return fmt.Sprintf("http://irislinks.net:83/xmltv.php?username=%s&password=%s", i.BaseConfig.Username, i.BaseConfig.Password) +} + +// ParseTrack matches the provided M3U track an XMLTV channel and returns a ProviderChannel. +func (i *iris) ParseTrack(track m3u.Track, channelMap map[string]xmltv.Channel) (*ProviderChannel, error) { + nameVal := track.Name + if i.BaseConfig.NameKey != "" { + nameVal = track.Tags[i.BaseConfig.NameKey] + } + + logoVal := track.Tags["tvg-logo"] + if i.BaseConfig.LogoKey != "" { + logoVal = track.Tags[i.BaseConfig.LogoKey] + } + + pChannel := &ProviderChannel{ + Name: nameVal, + Logo: logoVal, + Number: 0, + StreamURL: track.URI, + StreamID: 0, + HD: strings.Contains(strings.ToLower(track.Name), "hd"), + StreamFormat: "Unknown", + Track: track, + OnDemand: false, + } + + epgVal := track.Tags["tvg-id"] + if i.BaseConfig.EPGMatchKey != "" { + epgVal = track.Tags[i.BaseConfig.EPGMatchKey] + } + + if xmlChan, ok := channelMap[epgVal]; ok { + pChannel.EPGMatch = epgVal + pChannel.EPGChannel = &xmlChan + } + + return pChannel, nil +} + +func (i *iris) ProcessProgramme(programme xmltv.Programme) *xmltv.Programme { + return &programme +} + +func (i *iris) Configuration() Configuration { + return i.BaseConfig +} + +func (i *iris) RegexKey() string { + return "group-title" +} diff --git a/internal/providers/main.go b/internal/providers/main.go index 5931408..122877d 100644 --- a/internal/providers/main.go +++ b/internal/providers/main.go @@ -49,6 +49,8 @@ func (i *Configuration) GetProvider() (Provider, error) { return newVaders(i) case "iptv-epg", "iptvepg": return newIPTVEPG(i) + case "iris", "iristv": + return newIris(i) default: return newCustomProvider(i) } From f2c1a81c4f5c32297416799dcf4835a6352cbcd5 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Mon, 20 Aug 2018 21:34:24 -0700 Subject: [PATCH 027/114] Lowercase tags --- internal/m3uplus/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/m3uplus/main.go b/internal/m3uplus/main.go index 43d2606..712e539 100644 --- a/internal/m3uplus/main.go +++ b/internal/m3uplus/main.go @@ -126,7 +126,7 @@ func decodeInfoLine(line string) (float64, string, map[string]string) { if val == "" { // If empty string find a number in [3] val = match[3] } - keyMap[match[1]] = val + keyMap[strings.ToLower(match[1])] = val } return durationFloat, title, keyMap From 6d4f90224080ec3a6e86e4e1672a9331a78171a5 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Mon, 20 Aug 2018 21:34:57 -0700 Subject: [PATCH 028/114] Add Area51 as a provider --- internal/providers/area51.go | 81 ++++++++++++++++++++++++++++++++++++ internal/providers/main.go | 2 + 2 files changed, 83 insertions(+) create mode 100644 internal/providers/area51.go diff --git a/internal/providers/area51.go b/internal/providers/area51.go new file mode 100644 index 0000000..e2c2f87 --- /dev/null +++ b/internal/providers/area51.go @@ -0,0 +1,81 @@ +package providers + +import ( + "fmt" + "strings" + + m3u "github.com/tellytv/telly/internal/m3uplus" + "github.com/tellytv/telly/internal/xmltv" +) + +// http://iptv-area-51.tv:2095/get.php?username=username&password=password&type=m3uplus&output=ts +// http://iptv-area-51.tv:2095/xmltv.php?username=username&password=password + +type area51 struct { + BaseConfig Configuration +} + +func newArea51(config *Configuration) (Provider, error) { + return &area51{*config}, nil +} + +func (i *area51) Name() string { + return "Area51" +} + +func (i *area51) PlaylistURL() string { + return fmt.Sprintf("http://iptv-area-51.tv:2095/get.php?username=%s&password=%s&type=m3u_plus&output=ts", i.BaseConfig.Username, i.BaseConfig.Password) +} + +func (i *area51) EPGURL() string { + return fmt.Sprintf("http://iptv-area-51.tv:2095/xmltv.php?username=%s&password=%s", i.BaseConfig.Username, i.BaseConfig.Password) +} + +// ParseTrack matches the provided M3U track an XMLTV channel and returns a ProviderChannel. +func (i *area51) ParseTrack(track m3u.Track, channelMap map[string]xmltv.Channel) (*ProviderChannel, error) { + nameVal := track.Name + if i.BaseConfig.NameKey != "" { + nameVal = track.Tags[i.BaseConfig.NameKey] + } + + logoVal := track.Tags["tvg-logo"] + if i.BaseConfig.LogoKey != "" { + logoVal = track.Tags[i.BaseConfig.LogoKey] + } + + pChannel := &ProviderChannel{ + Name: nameVal, + Logo: logoVal, + Number: 0, + StreamURL: track.URI, + StreamID: 0, + HD: strings.Contains(strings.ToLower(track.Name), "hd"), + StreamFormat: "Unknown", + Track: track, + OnDemand: false, + } + + epgVal := track.Tags["tvg-id"] + if i.BaseConfig.EPGMatchKey != "" { + epgVal = track.Tags[i.BaseConfig.EPGMatchKey] + } + + if xmlChan, ok := channelMap[epgVal]; ok { + pChannel.EPGMatch = epgVal + pChannel.EPGChannel = &xmlChan + } + + return pChannel, nil +} + +func (i *area51) ProcessProgramme(programme xmltv.Programme) *xmltv.Programme { + return &programme +} + +func (i *area51) Configuration() Configuration { + return i.BaseConfig +} + +func (i *area51) RegexKey() string { + return "group-title" +} diff --git a/internal/providers/main.go b/internal/providers/main.go index 122877d..3bddfbc 100644 --- a/internal/providers/main.go +++ b/internal/providers/main.go @@ -51,6 +51,8 @@ func (i *Configuration) GetProvider() (Provider, error) { return newIPTVEPG(i) case "iris", "iristv": return newIris(i) + case "area51": + return newArea51(i) default: return newCustomProvider(i) } From a4d0dcb8ca71bc11ad7dab24705894d2a30616c2 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Mon, 20 Aug 2018 23:02:57 -0700 Subject: [PATCH 029/114] Support including only specific channels --- internal/providers/main.go | 3 +++ lineup.go | 15 ++++++++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/internal/providers/main.go b/internal/providers/main.go index 3bddfbc..41c199b 100644 --- a/internal/providers/main.go +++ b/internal/providers/main.go @@ -35,6 +35,9 @@ type Configuration struct { Favorites []string FavoriteTag string + IncludeOnly []string + IncludeOnlyTag string + CacheFiles bool NameKey string diff --git a/lineup.go b/lineup.go index 05f0d7a..275432c 100644 --- a/lineup.go +++ b/lineup.go @@ -261,10 +261,14 @@ func (l *lineup) processProviderChannel(channel *providers.ProviderChannel, prog func (l *lineup) FilterTrack(provider providers.Provider, track m3u.Track) bool { config := provider.Configuration() - if config.Filter == "" { + if config.Filter == "" && len(config.IncludeOnly) == 0 { return true } + if v, ok := track.Tags[config.IncludeOnlyTag]; len(config.IncludeOnly) > 0 && ok { + return contains(config.IncludeOnly, v) + } + filterRegex, regexErr := regexp.Compile(config.Filter) if regexErr != nil { log.WithError(regexErr).Panicln("your regex is invalid") @@ -832,3 +836,12 @@ func countDigits(i int) int { } return count } + +func contains(s []string, e string) bool { + for _, ss := range s { + if e == ss { + return true + } + } + return false +} From d824e2cde8bffe3ad1ffe3e3a995c85238a2c5f3 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Mon, 20 Aug 2018 23:03:46 -0700 Subject: [PATCH 030/114] Possibly fix some weird behavior --- internal/providers/vaders.go | 4 +++- internal/xmltv/xmltv.go | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/internal/providers/vaders.go b/internal/providers/vaders.go index 40d705b..9dcac0b 100644 --- a/internal/providers/vaders.go +++ b/internal/providers/vaders.go @@ -123,7 +123,9 @@ func (v *vader) ProcessProgramme(programme xmltv.Programme) *xmltv.Programme { programme.Titles[idx].Value = strings.Replace(title.Value, " [New!]", "", -1) } - programme.New = xmltv.ElementPresent(isNew) + if isNew { + programme.New = xmltv.ElementPresent(true) + } return &programme } diff --git a/internal/xmltv/xmltv.go b/internal/xmltv/xmltv.go index 1cf2ea6..7e957bd 100644 --- a/internal/xmltv/xmltv.go +++ b/internal/xmltv/xmltv.go @@ -142,7 +142,7 @@ type Programme struct { PreviouslyShown *PreviouslyShown `xml:"previously-shown,omitempty" json:"previously_shown,omitempty"` Premiere *CommonElement `xml:"premiere,omitempty" json:"premiere,omitempty"` LastChance *CommonElement `xml:"last-chance,omitempty" json:"last_chance,omitempty"` - New ElementPresent `xml:"new" json:"new,omitempty"` + New ElementPresent `xml:"new>placeholder" json:"new,omitempty"` Subtitles []Subtitle `xml:"subtitles,omitempty" json:"subtitles,omitempty"` Ratings []Rating `xml:"rating,omitempty" json:"ratings,omitempty"` StarRatings []Rating `xml:"star-rating,omitempty" json:"star_ratings,omitempty"` From 54671b48c2749594d9d53323dbc02260f46ccbbe Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Mon, 20 Aug 2018 23:04:06 -0700 Subject: [PATCH 031/114] Maybe fix TV shows for Plex? --- lineup.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/lineup.go b/lineup.go index 275432c..5108738 100644 --- a/lineup.go +++ b/lineup.go @@ -423,7 +423,13 @@ func (l *lineup) prepareEPG(provider providers.Provider, cacheFiles bool) (map[s for _, programmes := range haveAllInfo { for _, programme := range programmes { - epgProgrammeMap[programme.Channel] = append(epgProgrammeMap[programme.Channel], *provider.ProcessProgramme(programme)) + processedProgram := *provider.ProcessProgramme(programme) + if processedProgram.Start != nil { + if !processedProgram.Start.Time.IsZero() { + processedProgram.EpisodeNums = append(processedProgram.EpisodeNums, xmltv.EpisodeNum{System: "original-air-date", Value: processedProgram.Start.Time.Format("2006-01-02 15:04:05")}) + } + } + epgProgrammeMap[programme.Channel] = append(epgProgrammeMap[programme.Channel], processedProgram) } } From ba9beacef09b26f5f6785599da92975ea1b95977 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Mon, 20 Aug 2018 23:04:23 -0700 Subject: [PATCH 032/114] Cleanup .promu.yml, limit to certain platforms for crossbuild --- .promu.yml | 34 +++++++++++++++++++++++----------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/.promu.yml b/.promu.yml index e3986e5..eeb4831 100644 --- a/.promu.yml +++ b/.promu.yml @@ -1,14 +1,26 @@ repository: - path: github.com/tellytv/telly + path: github.com/tellytv/telly build: - flags: -a -tags netgo - ldflags: | - -X {{repoPath}}/vendor/github.com/prometheus/common/version.Version={{.Version}} - -X {{repoPath}}/vendor/github.com/prometheus/common/version.Revision={{.Revision}} - -X {{repoPath}}/vendor/github.com/prometheus/common/version.Branch={{.Branch}} - -X {{repoPath}}/vendor/github.com/prometheus/common/version.BuildUser={{user}}@{{host}} - -X {{repoPath}}/vendor/github.com/prometheus/common/version.BuildDate={{date "20060102-15:04:05"}} + flags: -a -tags netgo + ldflags: | + -X {{repoPath}}/vendor/github.com/prometheus/common/version.Version={{.Version}} + -X {{repoPath}}/vendor/github.com/prometheus/common/version.Revision={{.Revision}} + -X {{repoPath}}/vendor/github.com/prometheus/common/version.Branch={{.Branch}} + -X {{repoPath}}/vendor/github.com/prometheus/common/version.BuildUser={{user}}@{{host}} + -X {{repoPath}}/vendor/github.com/prometheus/common/version.BuildDate={{date "20060102-15:04:05"}} tarball: - files: - - LICENSE - - NOTICE + files: + - LICENSE + - NOTICE +crossbuild: + platforms: + - linux/amd64 + - linux/386 + - darwin/amd64 + - darwin/386 + - windows/amd64 + - windows/386 + - freebsd/amd64 + - freebsd/386 + - linux/arm + - linux/arm64 From 51b3215193c5f593b10414ea1f93fafb21eae0db Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Mon, 20 Aug 2018 23:04:34 -0700 Subject: [PATCH 033/114] Update Gopkg.toml --- Gopkg.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Gopkg.toml b/Gopkg.toml index 546090b..0ba7f07 100644 --- a/Gopkg.toml +++ b/Gopkg.toml @@ -49,6 +49,10 @@ name = "github.com/sirupsen/logrus" version = "1.0.6" +[[constraint]] + name = "github.com/tellytv/go.schedulesdirect" + version = "master" + [prune] go-tests = true unused-packages = true From fd0178908564edf5c5c36051982e6df4e9d49eb9 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Mon, 20 Aug 2018 23:04:48 -0700 Subject: [PATCH 034/114] Bump version --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 238afc2..a3fdef3 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.0.1 +1.1.0.2 From 906a1cf162c5f46bbccf7dec1a93eff0784d465d Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Tue, 21 Aug 2018 00:02:47 -0700 Subject: [PATCH 035/114] Fix for XMLTV tag --- internal/providers/vaders.go | 3 ++- internal/xmltv/xmltv.go | 7 +++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/internal/providers/vaders.go b/internal/providers/vaders.go index 9dcac0b..4344724 100644 --- a/internal/providers/vaders.go +++ b/internal/providers/vaders.go @@ -124,7 +124,8 @@ func (v *vader) ProcessProgramme(programme xmltv.Programme) *xmltv.Programme { } if isNew { - programme.New = xmltv.ElementPresent(true) + elm := xmltv.ElementPresent(true) + programme.New = &elm } return &programme diff --git a/internal/xmltv/xmltv.go b/internal/xmltv/xmltv.go index 7e957bd..e8a29a4 100644 --- a/internal/xmltv/xmltv.go +++ b/internal/xmltv/xmltv.go @@ -142,7 +142,7 @@ type Programme struct { PreviouslyShown *PreviouslyShown `xml:"previously-shown,omitempty" json:"previously_shown,omitempty"` Premiere *CommonElement `xml:"premiere,omitempty" json:"premiere,omitempty"` LastChance *CommonElement `xml:"last-chance,omitempty" json:"last_chance,omitempty"` - New ElementPresent `xml:"new>placeholder" json:"new,omitempty"` + New *ElementPresent `xml:"new" json:"new,omitempty"` Subtitles []Subtitle `xml:"subtitles,omitempty" json:"subtitles,omitempty"` Ratings []Rating `xml:"rating,omitempty" json:"ratings,omitempty"` StarRatings []Rating `xml:"star-rating,omitempty" json:"star_ratings,omitempty"` @@ -168,7 +168,10 @@ type ElementPresent bool // MarshalXML used to determine if the element is present or not. see https://stackoverflow.com/a/46516243 func (c *ElementPresent) MarshalXML(e *xml.Encoder, start xml.StartElement) error { - return e.EncodeElement(nil, start) + if c == nil { + return e.EncodeElement(nil, start) + } + return e.EncodeElement("", start) } // UnmarshalXML used to determine if the element is present or not. see https://stackoverflow.com/a/46516243 From 85a53b438262fa3f58abf4fc95c90620ffb32d9b Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Tue, 21 Aug 2018 00:03:11 -0700 Subject: [PATCH 036/114] Improve logic around adding original-air-date --- lineup.go | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/lineup.go b/lineup.go index 5108738..8030c12 100644 --- a/lineup.go +++ b/lineup.go @@ -11,6 +11,7 @@ import ( "sort" "strconv" "strings" + "time" "github.com/spf13/viper" "github.com/tellytv/go.schedulesdirect" @@ -424,9 +425,25 @@ func (l *lineup) prepareEPG(provider providers.Provider, cacheFiles bool) (map[s for _, programmes := range haveAllInfo { for _, programme := range programmes { processedProgram := *provider.ProcessProgramme(programme) - if processedProgram.Start != nil { - if !processedProgram.Start.Time.IsZero() { - processedProgram.EpisodeNums = append(processedProgram.EpisodeNums, xmltv.EpisodeNum{System: "original-air-date", Value: processedProgram.Start.Time.Format("2006-01-02 15:04:05")}) + hasXMLTV := false + itemType := "" + for _, epNum := range processedProgram.EpisodeNums { + if epNum.System == "dd_progid" { + idType, _, _, _, _, extractErr := extractDDProgID(epNum.Value) + if extractErr != nil { + log.WithError(extractErr).Errorln("error extracting dd_progid") + continue + } + itemType = idType + } + if epNum.System == "xmltv_ns" { + hasXMLTV = true + } + } + if (itemType == "SH" || itemType == "EP") && !hasXMLTV { + t := time.Time(processedProgram.Date) + if !t.IsZero() { + processedProgram.EpisodeNums = append(processedProgram.EpisodeNums, xmltv.EpisodeNum{System: "original-air-date", Value: t.Format("2006-01-02 15:04:05")}) } } epgProgrammeMap[programme.Channel] = append(epgProgrammeMap[programme.Channel], processedProgram) From bf7b5824baf8916ebf32a7f0fde40aa771a52a8f Mon Sep 17 00:00:00 2001 From: Guy Spronck Date: Tue, 21 Aug 2018 18:40:17 +0200 Subject: [PATCH 037/114] Add CORS headers for Angular --- routes.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/routes.go b/routes.go index 203dcef..0fa958f 100644 --- a/routes.go +++ b/routes.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" ssdp "github.com/koron/go-ssdp" "github.com/sirupsen/logrus" @@ -30,6 +31,7 @@ func serve(lineup *lineup) { } router := gin.New() + router.Use(cors.Default()) router.Use(gin.Recovery()) if viper.GetBool("log.logrequests") { @@ -88,9 +90,11 @@ func serve(lineup *lineup) { } } - log.Infof("telly is live and on the air!") + log.Infof("telly is live and on the air! NOW WITH CORS") log.Infof("Broadcasting from http://%s/", viper.GetString("web.listen-address")) log.Infof("EPG URL: http://%s/epg.xml", viper.GetString("web.listen-address")) + + if err := router.Run(viper.GetString("web.listen-address")); err != nil { log.WithError(err).Panicln("Error starting up web server") } From 8ee70559811c333548752ea54171ab609eef9b99 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Tue, 21 Aug 2018 18:24:56 -0700 Subject: [PATCH 038/114] Add frontend --- .gitmodules | 3 +++ a_main-packr.go | 18 ++++++++++++++++++ frontend | 1 + routes.go | 5 +++++ 4 files changed, 27 insertions(+) create mode 100644 .gitmodules create mode 100644 a_main-packr.go create mode 160000 frontend diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..c601d1c --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "frontend"] + path = frontend + url = https://github.com/tellytv/frontend.git diff --git a/a_main-packr.go b/a_main-packr.go new file mode 100644 index 0000000..3d00397 --- /dev/null +++ b/a_main-packr.go @@ -0,0 +1,18 @@ +// Code generated by github.com/gobuffalo/packr. DO NOT EDIT. + +package main + +import "github.com/gobuffalo/packr" + +// You can use the "packr clean" command to clean up this, +// and any other packr generated files. +func init() { + packr.PackJSONBytes("./frontend/dist/telly-fe", "3rdpartylicenses.txt", "\"H4sIAAAAAAAA/+xU3W7bNhi951Mc5KoFVC8Ntq7YVRmLtohJpEDRzXwpS7TFTCYNkW6QPf1A2WmR7RGaK0Hf3/nO+YjT+cl8eAxf7ha/LX4nFddk6U/Pkz0MEe+697i7/fjrh7vbj5+RG2cD6nMY/m4n842Q2kxHG4L1DjZgMJPZPeMwtS6aPsN+MgZ+j25op4PJED1a94yTmYJ38LvYWmfdAS06f3omfo842IDg9/GpnQxa16MNwXe2jaZH77vz0bjYxoS3t6MJeBcHg5vm2nHzfgbpTTsS65ByLyk82Tj4c8RkQpxsl2ZksK4bz33a4SU92qO9IqT2WYVAosc5mGzeM8PR93afvmamdTrvRhuGDL1No3fnaDKEFOyMS12t63/xE4IZR9L5kzUBM9cf2801afVTEjReJQop8jT442smNpD9eXI2DGbu6T2CnxEfTRdTJJXv/Tj6p0St8663iVH4gxA9GLQ7/83MXC5Hdj7a7iL3fIDTj6teU2FoxxE7cxXM9LCOpNALnSnBh9i6aNsRJz/NeP+luSBEFwyNXOkHqhh4g1rJrzxnOW5oA97cZHjgupAbjQeqFBV6C7kCFVv8yUWegf1VK9Y0kIrwqi45yzNwsSw3ORdr3G80hNQoecU1y6ElEuB1FGdNGlYxtSyo0PSel1xvM7LiWqSZK6lAUVOl+XJTUoV6o2rZMFCRQ0jBxUpxsWYVE3oBLiAk2FcmNJqClmWCInSjC6nSfljKeqv4utAoZJkz1eCeoeT0vmQXKLHFsqS8ypDTiq7Z3CV1wRRJZZft8FCwFEp4VIAuNZci0VhKoRVd6gxaKv299YE3LANVvEmCrJSsMpLklKtUwkXqE+wyJUmNVxeRav7fNOz7QOSMllysG3Dx6nwLQv7xziwew5fbxefF3afZO9IDq7hGeXn95P9m8uliJmvvD6PJwF23eHOSNyd5c5Kf10n+DQAA//82zaErgwgAAA==\"") + packr.PackJSONBytes("./frontend/dist/telly-fe", "assets/logo.svg", "\"H4sIAAAAAAAA/yyQT4/jIAzFv4rluxMwkGFWpYedSy97nTui+YOUJhVkSDWffkUayYKnn20eepdcRvApepri/d4vDrf00yPc/ebpmfohvhwOPp8khrWOFIQw+5wd5jJSXOa49ESDh8HTVuq5EwuEtM69w/gYEV6PeckOp217/mnbfd+bXTVrGlsWQrS5jAgl9vvf9eVQgIBOCzCS8Xp5+m2CIc6zw/CTUr9sX+u8JoS7w3/mk0HctP1i2Rioi6fQtigWQQB3jXlDbd81sTaNLIonkp0IJD+aDxCkGKRuVL0V5yrhkKB4UlaH9xgoprNHijOdK+86Hiyk+GY+ORzO9Sd02pO239oGQbVzQDogafv7INmBMnzr9HenJyO5sLW/eL20NYDrpWZ0/R8AAP//F4XLEq8BAAA=\"") + packr.PackJSONBytes("./frontend/dist/telly-fe", "favicon.ico", "\"H4sIAAAAAAAA/+ybT0gcVxzHP/4p1qLt4qEUW90VqrWnSileWtmlx55KDx4KtaUt1UKp5JCboIeQYyDkzyYecsohkEPwFA+CQXIIuQQSBE/GRBOEgAbC6kY3O+HN/pY8htk4Mzu7bxLfF748dpj3vt837+2b9+c30EIbIyMqzXBtEEaBTEZ+p2B9EFKpyu+pdjg9CsPACPAHlesuvsTCwiIcPgK+BXJAtsn8Gmivw/vHQB7YBfYM8ClwAmiL6P9noAg4BrkJfBXR/5+GvTvS9t9F9D8EPDDs/4b046hQr8FTwMWAvABcBV749IPLIcpRPAn01eE9KgaAxx7/S8CHDdTskrqm66Qq4wcZO3T/t6U/9sdQfpfm+wNgHFgG1oGNGLgFlDz+94FHMZS9Ll7HxftfwE4Cxpmw3BHvawnwEpVrPu38LtHPu3pfrEgfSxJXxNtRdVL39QCdCWOPeDvK/7LcnzR0ijfr3wysf7Ow/s3C+jcL698srH+zsP7Nwvo3C+vfLI6L/yfAfMh91mZwXrx5/ZYD1CmpLMtepWkfUam8zybgjCgKi+K9G5gBHgIFQ+d0YVgQrzPiHTnfGwDGDJ2TBmVOPA7UcSZpYfHewHERIKUyarnprE86By3u/aW0o6W3qPzT5nADkWqiQ8lsJD+thWr9qvUN+lyHwY3OyulxWqn42vcYoCPmc5ow74VfgJtUmj0ungc+C6CtesndBs1N/g6gr57V9QZo7wI/BXz+XwC/AVPApA9VPa5oZat5yzTwT437FX+Uc3U/dHniD/qAz4HeGvwU+F9bI9yTWKJa9/dKeVWNfi1WQc1fFiPECzzT6v9S4kLC5F8U7fsG58pK+5Vn7tzoea6+pvBqT2tz3UZwTDT81jV70h6NRla0/PRzTdDPWX2rb/WtvtVP1Ph/AJyRudK/DeKkaBxouocG5x+HEj9rSn9JYl/Pyh56nOuNt3FZNIekT7QaiJtrbUJfTzwcDSVIF4VOKes45ay7/VK9N+04xSj8xHGeV8twt3M0DMuCM6PvU9TzZcrRSMv6TR8XpoTe8cLv+4YJieEvaCzJWmxPu6bm2Oe03aluWdd9A/wK/C5lqfQOsA38J+vOCeH3kkflXZA11pbEe+ssyFiy6bm+KXkW5P++Kt/tXPIwL2dRftdXtfEiH+F5523+2PIvaOcNQZjV2n5b+ul+SJYk71jEd/Ok5K0Llb1N2GiD8uyb69lSber5XgcAAP//wTJ26O46AAA=\"") + packr.PackJSONBytes("./frontend/dist/telly-fe", "index.html", "\"H4sIAAAAAAAA/5RRwXLrIAy85yt4Oj+HaU89gPsT/QEFy7FSGTygOPHfdzBtM9NTe4JdsbvsyP0bUtBtITPpLP3B1cMIxrMHilAJwqE/GONmUjRhwlxIPVx17F5gHyirUP9GIpuzDVT6hIXMlGn0YGeMeCYLD6OIM3lYmW5LygompKgU1cONB538QCsH6nbw33BkZZSuBBTyT81GOL6bTOKBQ4pgagsPPNege9e4Fj/iWuGRQ3pd/XNt9RAX3YTKRKRfzxtzDKVA72zr705p2PZYXJYup6S9s9/Xgysh86Kff1C6q73gio0FU3LwkK9ReabjZbdto/43wiXJNrJI+bt0Ro4/VLY1cbYt/CMAAP//mAHIYQECAAA=\"") + packr.PackJSONBytes("./frontend/dist/telly-fe", "main.js", "\"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\"") + packr.PackJSONBytes("./frontend/dist/telly-fe", "polyfills.js", "\"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\"") + packr.PackJSONBytes("./frontend/dist/telly-fe", "runtime.js", "\"H4sIAAAAAAAA/4xTTW/bMAz9K0kOhgSzgrNjXGL3AUN3F4RCUejGmyoJstSscPzfBzlxsgIdsBv18R7Jx8d1l51JvXcs8nGJV8SIj52P7E3HVYIABkk2CjSS3CrokOQXBRYbGFCq1j4aYcm9pGNr65p7GdBIq1RVDSLk4ci8DEo2ikMJsGkLdVr1bqX50/4nmSRC9Mmn90DiqIenk/sRfaCY3oXR1jINiVcVizIp1DIpPjP0VdUz4u2wJOeDGI59lxhnvI2UcnSrPFcgdAj2nWXozmepODjGp1u3jt2bjUDYtPSYF06q6/urwyxJQcJ1Awa3rXl0yz9T17xZI3rppCmts4TrLW9TVbEshmB7Q4weHmDLIWJgQQzoiiZ8ulYap1ltHCfwODa7ZoJc1L3VGcpU+o4lSYpfQSUW9Dv4mIb2UmK5wrHfEdjdegvXx904TYsmsYBmYd2CBQf3OHBwwpYmb3dTEK8YIQiDCYI44N03QOD4GIQvIT+frxM9UNc7WuY4fxuNd13/kqPeW5prc/mVrqcGXijt3MQnCCLi3778F+Pm+ZmG7/6QLW1gfNM2F54Lg/vAUJQhjFUVxR3z9faDj4syJYnONk27Tx5vAgZxYAQbvQHiQCWd/yDIDfJf7i6AwhFws5lnaPDUu4M/iRPtgza/vg3ehc/uiplBo7l4fN+7AzO8vRyRwKARw2y8y8IU7q6s331fu7rmxIzsFJ9T96jbshxMKt7+CQAA//9/VcMtHQQAAA==\"") + packr.PackJSONBytes("./frontend/dist/telly-fe", "styles.css", "\"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\"") +} diff --git a/frontend b/frontend new file mode 160000 index 0000000..05a69f4 --- /dev/null +++ b/frontend @@ -0,0 +1 @@ +Subproject commit 05a69f4c0f800f807f3b8c28557f401844655fb9 diff --git a/routes.go b/routes.go index 203dcef..146fb3b 100644 --- a/routes.go +++ b/routes.go @@ -10,6 +10,7 @@ import ( "time" "github.com/gin-gonic/gin" + "github.com/gobuffalo/packr" ssdp "github.com/koron/go-ssdp" "github.com/sirupsen/logrus" "github.com/spf13/viper" @@ -88,6 +89,10 @@ func serve(lineup *lineup) { } } + box := packr.NewBox("./frontend/dist/telly-fe") + + router.StaticFS("/manage", box) + log.Infof("telly is live and on the air!") log.Infof("Broadcasting from http://%s/", viper.GetString("web.listen-address")) log.Infof("EPG URL: http://%s/epg.xml", viper.GetString("web.listen-address")) From 2130ad62bf17f6279a2201a7badaeecd60070fe9 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Tue, 21 Aug 2018 20:56:01 -0700 Subject: [PATCH 039/114] Initial ffmpeg support --- routes.go | 77 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 76 insertions(+), 1 deletion(-) diff --git a/routes.go b/routes.go index 146fb3b..1ce6a7f 100644 --- a/routes.go +++ b/routes.go @@ -1,9 +1,13 @@ package main import ( + "bufio" + "bytes" "encoding/xml" "fmt" + "io" "net/http" + "os/exec" "sort" "strconv" "strings" @@ -174,9 +178,61 @@ func stream(lineup *lineup) gin.HandlerFunc { if channel, ok := lineup.channels[channelID]; ok { log.Infof("Serving channel number %d", channelID) - c.Redirect(http.StatusMovedPermanently, channel.providerChannel.Track.URI) + + if !viper.IsSet("iptv.ffmpeg") { + c.Redirect(http.StatusMovedPermanently, channel.providerChannel.Track.URI) + return + } + + log.Infoln("Transcoding stream with ffmpeg") + + run := exec.Command("ffmpeg", "-re", "-i", channel.providerChannel.Track.URI, "-codec", "copy", "-bsf:v", "h264_mp4toannexb", "-f", "mpegts", "-tune", "zerolatency", "pipe:1") + ffmpegout, err := run.StdoutPipe() + if err != nil { + log.WithError(err).Errorln("StdoutPipe Error") + return + } + + stderr, stderrErr := run.StderrPipe() + if stderrErr != nil { + log.WithError(stderrErr).Errorln("Error creating ffmpeg stderr pipe") + } + + if startErr := run.Start(); startErr != nil { + log.WithError(startErr).Errorln("Error starting ffmpeg") + return + } + + go func() { + scanner := bufio.NewScanner(stderr) + scanner.Split(split) + for scanner.Scan() { + log.Println(scanner.Text()) + } + }() + + continueStream := true + + c.Stream(func(w io.Writer) bool { + defer func() { + log.Infoln("Stopped streaming", channelID) + if killErr := run.Process.Kill(); killErr != nil { + panic(killErr) + } + continueStream = false + return + }() + if _, copyErr := io.Copy(w, ffmpegout); copyErr != nil { + log.WithError(copyErr).Errorln("Error when copying data") + continueStream = false + return false + } + return continueStream + }) + return } + c.AbortWithError(http.StatusNotFound, fmt.Errorf("unknown channel number %d", channelID)) } } @@ -242,3 +298,22 @@ func setupSSDP(baseAddress, deviceName, deviceUUID string) (*ssdp.Advertiser, er return adv, nil } + +func split(data []byte, atEOF bool) (advance int, token []byte, spliterror error) { + if atEOF && len(data) == 0 { + return 0, nil, nil + } + if i := bytes.IndexByte(data, '\n'); i >= 0 { + // We have a full newline-terminated line. + return i + 1, data[0:i], nil + } + if i := bytes.IndexByte(data, '\r'); i >= 0 { + // We have a cr terminated line + return i + 1, data[0:i], nil + } + if atEOF { + return len(data), data, nil + } + + return 0, nil, nil +} From f1de7f04430f9f6d35ad7caf55d4d5360a1d681c Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Tue, 21 Aug 2018 21:05:45 -0700 Subject: [PATCH 040/114] Update Gopkg --- Gopkg.lock | 26 ++++++++++++++++++++++++++ Gopkg.toml | 4 ---- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/Gopkg.lock b/Gopkg.lock index 8499add..91c2ee3 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -17,6 +17,14 @@ revision = "c2828203cd70a50dcccfb2761f8b1f8ceef9a8e9" version = "v1.4.7" +[[projects]] + digest = "1:2b59aca2665ff804f6606c8829eaee133ddd3aefbc841014660d961b0034f888" + name = "github.com/gin-contrib/cors" + packages = ["."] + pruneopts = "UT" + revision = "cf4846e6a636a76237a28d9286f163c132e841bc" + version = "v1.2" + [[projects]] branch = "master" digest = "1:36fe9527deed01d2a317617e59304eb2c4ce9f8a24115bcc5c2e37b3aee5bae4" @@ -37,6 +45,14 @@ revision = "d459835d2b077e44f7c9b453505ee29881d5d12d" version = "v1.2" +[[projects]] + digest = "1:35534a9283f212bdc542697dfca3c2700f2b2b1771e409476f08701b44c1709a" + name = "github.com/gobuffalo/packr" + packages = ["."] + pruneopts = "UT" + revision = "1aab5672bd385f2a7da18bffa961912e7642ea79" + version = "v1.13.2" + [[projects]] digest = "1:15042ad3498153684d09f393bbaec6b216c8eec6d61f63dff711de7d64ed8861" name = "github.com/golang/protobuf" @@ -127,6 +143,14 @@ revision = "c01d1270ff3e442a8a57cddc1c92dc1138598194" version = "v1.2.0" +[[projects]] + digest = "1:40e195917a951a8bf867cd05de2a46aaf1806c50cf92eebf4c16f78cd196f747" + name = "github.com/pkg/errors" + packages = ["."] + pruneopts = "UT" + revision = "645ef00459ed84a119197bfb8d8205042c6df63d" + version = "v0.8.0" + [[projects]] digest = "1:d14a5f4bfecf017cb780bdde1b6483e5deb87e12c332544d2c430eda58734bcb" name = "github.com/prometheus/client_golang" @@ -321,7 +345,9 @@ analyzer-name = "dep" analyzer-version = 1 input-imports = [ + "github.com/gin-contrib/cors", "github.com/gin-gonic/gin", + "github.com/gobuffalo/packr", "github.com/koron/go-ssdp", "github.com/kr/pretty", "github.com/mitchellh/mapstructure", diff --git a/Gopkg.toml b/Gopkg.toml index 0ba7f07..546090b 100644 --- a/Gopkg.toml +++ b/Gopkg.toml @@ -49,10 +49,6 @@ name = "github.com/sirupsen/logrus" version = "1.0.6" -[[constraint]] - name = "github.com/tellytv/go.schedulesdirect" - version = "master" - [prune] go-tests = true unused-packages = true From d0b9a702d1aaddacc6b8ab3cbf26bf091551127c Mon Sep 17 00:00:00 2001 From: EnorMOZ <13998170+EnorMOZ@users.noreply.github.com> Date: Thu, 23 Aug 2018 14:33:37 -0400 Subject: [PATCH 041/114] Telly ffmpeg image --- Dockerfile.ffmpeg | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 Dockerfile.ffmpeg diff --git a/Dockerfile.ffmpeg b/Dockerfile.ffmpeg new file mode 100644 index 0000000..ec62afe --- /dev/null +++ b/Dockerfile.ffmpeg @@ -0,0 +1,4 @@ +FROM jrottenberg/ffmpeg:4.0-alpine +COPY --from=tellytv/telly:dev /app /app +EXPOSE 6077 +ENTRYPOINT ["/app"] From 3ae6861baf30da303c048228d450883b001ee391 Mon Sep 17 00:00:00 2001 From: Chaz Larson Date: Fri, 24 Aug 2018 13:58:10 -0500 Subject: [PATCH 042/114] Add ffmpeg flag --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 27f0233..d815010 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ Here's an example configuration file. You will need to create this file. It sho Streams = 1 Starting-Channel = 10000 XMLTV-Channels = true + FFMpeg = true # if true, streams are buffered through ffmpeg; ffmpeg must be on your $PATH [Log] Level = "info" From 1f373f358796c74ccacba69d238c2ffb3a626195 Mon Sep 17 00:00:00 2001 From: Chaz Larson Date: Fri, 24 Aug 2018 15:34:53 -0500 Subject: [PATCH 043/114] Add supported providers to comments --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d815010..1377a04 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ Here's an example configuration file. You will need to create this file. It sho [[Source]] Name = "" - Provider = "Vaders" + Provider = "Vaders" # named providers currently supported are "Vaders", "area51", "Iris" Username = "" Password = "" Filter = "Sports|Premium Movies|United States.*|USA" From 3a56ec25170d09ee8e44728f75848312870499ff Mon Sep 17 00:00:00 2001 From: Chaz Larson Date: Fri, 24 Aug 2018 17:27:47 -0500 Subject: [PATCH 044/114] Update readme Add comments to config file example Add ffmpeg notes Expand Docker notes. --- README.md | 83 ++++++++++++++++++++++++++++++++++--------------------- 1 file changed, 51 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 1377a04..30acec9 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,12 @@ IPTV proxy for Plex Live written in Golang # Configuration -Here's an example configuration file. You will need to create this file. It should be placed in `/etc/telly/telly.config.toml` or `$HOME/.telly/telly.config.toml` or `telly.config.toml` in the directory that telly is running from. +Here's an example configuration file. **You will need to create this file.** It should be placed in `/etc/telly/telly.config.toml` or `$HOME/.telly/telly.config.toml` or `telly.config.toml` in the directory that telly is running from. ```toml -[Discovery] - Device-Auth = "telly123" - Device-ID = 12345678 +[Discovery] # most likely you won't need to change anything here + Device-Auth = "telly123" # These settings are all related to how telly identifies + Device-ID = 12345678 # itself to Plex. Device-UUID = "" Device-Firmware-Name = "hdhomeruntc_atsc" Device-Firmware-Version = "20150826" @@ -19,38 +19,40 @@ Here's an example configuration file. You will need to create this file. It sho SSDP = true [IPTV] - Streams = 1 - Starting-Channel = 10000 - XMLTV-Channels = true - FFMpeg = true # if true, streams are buffered through ffmpeg; ffmpeg must be on your $PATH - + Streams = 1 # number of simultaneous streams that the telly virtual DVR will be able to provide + # This is often 1, but is set by your iptv provider; for example, Vaders provides 5 + Starting-Channel = 10000 # When telly assigns channel numbers it will start here + XMLTV-Channels = true # if true, any channel numbers specified in your M3U file will be used. + FFMpeg = true # if true, streams are buffered through ffmpeg; ffmpeg must be on your $PATH + # if you want to use this with Docker, be sure you use the correct docker image + [Log] - Level = "info" - Requests = true + Level = "info" # Only log messages at or above the given level. [debug, info, warn, error, fatal] + Requests = true # Log HTTP requests made to telly [Web] - Base-Address = "0.0.0.0:6077" - Listen-Address = "0.0.0.0:6077" + Base-Address = "0.0.0.0:6077" # Set this to the IP address of the machine telly runs on + Listen-Address = "0.0.0.0:6077" # this can stay as-is -[SchedulesDirect] - Username = "" - Password = "" +[SchedulesDirect] # If you have a Schedules Direct account, fill in details + Username = "" # This is under construction; Vader is the only provider + Password = "" # that works with it fully at this time [[Source]] - Name = "" - Provider = "Vaders" # named providers currently supported are "Vaders", "area51", "Iris" + Name = "" # Name is optional and is used mostly for logging purposes + Provider = "Vaders" # named providers currently supported are "Vaders", "area51", "Iris" Username = "" Password = "" Filter = "Sports|Premium Movies|United States.*|USA" - FilterKey = "tvg-name" # FilterKey normally defaults to whatever the provider file says is best, otherwise you must set this. - FilterRaw = false # FilterRaw will run your regex on the entire line instead of just specific keys. - Sort = "group-title" # Sort will alphabetically sort your channels by the M3U key provided + FilterKey = "tvg-name" # FilterKey normally defaults to whatever the provider file says is best, otherwise you must set this. + FilterRaw = false # FilterRaw will run your regex on the entire line instead of just specific keys. + Sort = "group-title" # Sort will alphabetically sort your channels by the M3U key provided [[Source]] Name = "" Provider = "IPTV-EPG" - Username = "M3U-Identifier" - Password = "XML-Identifier" + Username = "M3U-Identifier" # From http://iptv-epg.com/[M3U-Identifier].m3u + Password = "XML-Identifier" # From http://iptv-epg.com/[XML-Identifier].xml [[Source]] @@ -58,42 +60,59 @@ Here's an example configuration file. You will need to create this file. It sho M3U = "http://myprovider.com/playlist.m3u" EPG = "http://myprovider.com/epg.xml" ``` -You only need one source; the ones you are not using should be commented out or deleted. The filter-related keys can be used with any of the sources. +You only need one source; the ones you are not using should be commented out or deleted. The name and filter-related keys can be used with any of the sources. If you do not have a Schedules Direct account, that section can be removed or left blank. Set listen- and base-address to the IP address of the machine running telly. +# FFMpeg + +Telly can buffer the streams to Plex through ffmpeg. This has the potential for several benefits, but today it primarily: + +1. Allows support for stream formats that may cause problems for Plex directly. +1. Eliminates the use of redirects and makes it possible for telly to report exactly why a given stream failed. + +To take advantage of this, ffmpeg must be installed and available in your path. + # Docker +There are two different docker images available: + +## tellytv/telly:dev +The standard docker image for the dev branch + +## tellytv/telly:dev-ffmpeg +This docker image has ffmpeg preinstalled. If you want to use the ffmpeg feature, use this image. It may be safest to use this image generally, since it is not much larger than the standard image and allows you to turn the ffmpeg deatures on and off without requiring changes to your docker run command. The examples below use this image. + ## `docker run` ``` docker run -d \ --name='telly' \ --net='bridge' \ - -e TZ="Europe/Amsterdam" \ - -e 'TELLY_CONFIG_FILE'='/telly.config.toml' \ + -e TZ="America/Chicago" \ -p '6077:6077/tcp' \ - -v '/tmp/telly':'/tmp':'rw' \ - tellytv/telly --listen.base-address=localhost:6077 + -v /host/path/to/telly.config.toml:/etc/telly/telly.config.toml \ + --restart unless-stopped \ + tellytv/telly:dev-ffmpeg ``` ## docker-compose ``` telly: - image: tellytv/telly + image: tellytv/telly:dev-ffmpeg ports: - "6077:6077" environment: - TZ=Europe/Amsterdam - - TELLY_CONFIG_FILE=/telly.config.toml - command: -base=telly:6077 + volumes: + - /host/path/to/telly.config.toml:/etc/telly/telly.config.toml restart: unless-stopped ``` # Troubleshooting -Please free to open an issue if you run into any issues at all, I'll be more than happy to help. +Please free to open an issue if you run into any problems at all, we'll be more than happy to help. # Social From 8494e6ae0d4509d8f7c80ab00575277607009be5 Mon Sep 17 00:00:00 2001 From: Chaz Larson Date: Fri, 24 Aug 2018 17:53:05 -0500 Subject: [PATCH 045/114] Create ISSUE_TEMPLATE.md --- .github/ISSUE_TEMPLATE.md | 45 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE.md diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 0000000..936e12f --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,45 @@ + + +**telly release with the issue:** + + + +**Last working telly release (if known):** + + +**Operating environment (Docker/Windows/Linux/QNAP, etc.):** + + +**Description of problem:** + + + +**Contents of `telly.config.toml` [if you're using a version above 1.1]:** +```toml + +``` + +**Command line used to run telly [if applicable]:** +``` + +``` + +**Docker run command used to run telly [if applicable]:** +``` + +``` + +**telly or docker log:** +``` + +``` + +**Additional information:** + From ce9a279b9e70ce8a11c11b2e04f2cdf34c71c38c Mon Sep 17 00:00:00 2001 From: Chaz Larson Date: Fri, 24 Aug 2018 17:54:29 -0500 Subject: [PATCH 046/114] Delete ISSUE_TEMPLATE.md --- .github/ISSUE_TEMPLATE.md | 45 --------------------------------------- 1 file changed, 45 deletions(-) delete mode 100644 .github/ISSUE_TEMPLATE.md diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md deleted file mode 100644 index 936e12f..0000000 --- a/.github/ISSUE_TEMPLATE.md +++ /dev/null @@ -1,45 +0,0 @@ - - -**telly release with the issue:** - - - -**Last working telly release (if known):** - - -**Operating environment (Docker/Windows/Linux/QNAP, etc.):** - - -**Description of problem:** - - - -**Contents of `telly.config.toml` [if you're using a version above 1.1]:** -```toml - -``` - -**Command line used to run telly [if applicable]:** -``` - -``` - -**Docker run command used to run telly [if applicable]:** -``` - -``` - -**telly or docker log:** -``` - -``` - -**Additional information:** - From 6fc91d90535dd59e5630caaa33b7979d99914b39 Mon Sep 17 00:00:00 2001 From: Chaz Larson Date: Fri, 24 Aug 2018 20:26:28 -0500 Subject: [PATCH 047/114] Add note that M3U can be a file path. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 30acec9..d720ab7 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ Here's an example configuration file. **You will need to create this file.** It [[Source]] Provider = "Custom" - M3U = "http://myprovider.com/playlist.m3u" + M3U = "http://myprovider.com/playlist.m3u" # These can be either URLs or fully-qualified paths. EPG = "http://myprovider.com/epg.xml" ``` You only need one source; the ones you are not using should be commented out or deleted. The name and filter-related keys can be used with any of the sources. From e2c261259405602cb9d3f0426402a05b3ad51709 Mon Sep 17 00:00:00 2001 From: Chaz Larson Date: Sat, 25 Aug 2018 22:55:14 -0500 Subject: [PATCH 048/114] Fix some spelling --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index d720ab7..963a8ce 100644 --- a/README.md +++ b/README.md @@ -44,9 +44,9 @@ Here's an example configuration file. **You will need to create this file.** It Username = "" Password = "" Filter = "Sports|Premium Movies|United States.*|USA" - FilterKey = "tvg-name" # FilterKey normally defaults to whatever the provider file says is best, otherwise you must set this. - FilterRaw = false # FilterRaw will run your regex on the entire line instead of just specific keys. - Sort = "group-title" # Sort will alphabetically sort your channels by the M3U key provided + FilterKey = "group-title" # FilterKey normally defaults to whatever the provider file says is best, otherwise you must set this. + FilterRaw = false # FilterRaw will run your regex on the entire line instead of just specific keys. + Sort = "group-title" # Sort will alphabetically sort your channels by the M3U key provided [[Source]] Name = "" @@ -83,7 +83,7 @@ There are two different docker images available: The standard docker image for the dev branch ## tellytv/telly:dev-ffmpeg -This docker image has ffmpeg preinstalled. If you want to use the ffmpeg feature, use this image. It may be safest to use this image generally, since it is not much larger than the standard image and allows you to turn the ffmpeg deatures on and off without requiring changes to your docker run command. The examples below use this image. +This docker image has ffmpeg preinstalled. If you want to use the ffmpeg feature, use this image. It may be safest to use this image generally, since it is not much larger than the standard image and allows you to turn the ffmpeg features on and off without requiring changes to your docker run command. The examples below use this image. ## `docker run` ``` From b53eb053f9082de964f167f124bb4d7d76cae448 Mon Sep 17 00:00:00 2001 From: Chaz Larson Date: Sun, 26 Aug 2018 10:22:49 -0500 Subject: [PATCH 049/114] Update README.md --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 963a8ce..d59a4f9 100644 --- a/README.md +++ b/README.md @@ -34,9 +34,9 @@ Here's an example configuration file. **You will need to create this file.** It Base-Address = "0.0.0.0:6077" # Set this to the IP address of the machine telly runs on Listen-Address = "0.0.0.0:6077" # this can stay as-is -[SchedulesDirect] # If you have a Schedules Direct account, fill in details - Username = "" # This is under construction; Vader is the only provider - Password = "" # that works with it fully at this time +#[SchedulesDirect] # If you have a Schedules Direct account, UNCOMMENT THIS SECTION and fill in details +# Username = "" # This is under construction; Vader is the only provider +# Password = "" # that works with it fully at this time [[Source]] Name = "" # Name is optional and is used mostly for logging purposes From 461b9a93b24ea055c1a6c757ed6287de10603143 Mon Sep 17 00:00:00 2001 From: Chaz Larson Date: Sun, 26 Aug 2018 10:54:31 -0500 Subject: [PATCH 050/114] Update README.md --- README.md | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index d59a4f9..9cdccc7 100644 --- a/README.md +++ b/README.md @@ -19,8 +19,9 @@ Here's an example configuration file. **You will need to create this file.** It SSDP = true [IPTV] - Streams = 1 # number of simultaneous streams that the telly virtual DVR will be able to provide - # This is often 1, but is set by your iptv provider; for example, Vaders provides 5 + Streams = 1 # number of simultaneous streams that the telly virtual DVR will provide + # This is often 1, but is set by your iptv provider; for example, + # Vaders provides 5 Starting-Channel = 10000 # When telly assigns channel numbers it will start here XMLTV-Channels = true # if true, any channel numbers specified in your M3U file will be used. FFMpeg = true # if true, streams are buffered through ffmpeg; ffmpeg must be on your $PATH @@ -34,17 +35,19 @@ Here's an example configuration file. **You will need to create this file.** It Base-Address = "0.0.0.0:6077" # Set this to the IP address of the machine telly runs on Listen-Address = "0.0.0.0:6077" # this can stay as-is -#[SchedulesDirect] # If you have a Schedules Direct account, UNCOMMENT THIS SECTION and fill in details +#[SchedulesDirect] # If you have a Schedules Direct account, fill in details and then + # UNCOMMENT THIS SECTION # Username = "" # This is under construction; Vader is the only provider # Password = "" # that works with it fully at this time [[Source]] Name = "" # Name is optional and is used mostly for logging purposes Provider = "Vaders" # named providers currently supported are "Vaders", "area51", "Iris" - Username = "" - Password = "" + Username = "YOUR_IPTV_USERNAME" + Password = "YOUR_IPTV_PASSWORD" Filter = "Sports|Premium Movies|United States.*|USA" - FilterKey = "group-title" # FilterKey normally defaults to whatever the provider file says is best, otherwise you must set this. + FilterKey = "group-title" # FilterKey normally defaults to whatever the provider file says is best, + # otherwise you must set this. FilterRaw = false # FilterRaw will run your regex on the entire line instead of just specific keys. Sort = "group-title" # Sort will alphabetically sort your channels by the M3U key provided @@ -62,10 +65,6 @@ Here's an example configuration file. **You will need to create this file.** It ``` You only need one source; the ones you are not using should be commented out or deleted. The name and filter-related keys can be used with any of the sources. -If you do not have a Schedules Direct account, that section can be removed or left blank. - -Set listen- and base-address to the IP address of the machine running telly. - # FFMpeg Telly can buffer the streams to Plex through ffmpeg. This has the potential for several benefits, but today it primarily: @@ -112,7 +111,7 @@ telly: # Troubleshooting -Please free to open an issue if you run into any problems at all, we'll be more than happy to help. +Please free to [open an issue](https://github.com/tellytv/telly/issues) if you run into any problems at all, we'll be more than happy to help. # Social From 77e7109f9ed5ea3a040c4153ac78262f383aa681 Mon Sep 17 00:00:00 2001 From: Chaz Larson Date: Mon, 27 Aug 2018 10:29:11 -0500 Subject: [PATCH 051/114] Note about pre-release status. --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 9cdccc7..8927fcc 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,10 @@ IPTV proxy for Plex Live written in Golang +## ![#f03c15](https://placehold.it/15/f03c15/000000?text=+) THIS IS A PRERELEASE BETA ![#f03c15](https://placehold.it/15/f03c15/000000?text=+) + +It is under active develepment and things may change quickly and dramatically. Please join the discord server if you use this branch and be prepared for some tinkering and breakage. + # Configuration Here's an example configuration file. **You will need to create this file.** It should be placed in `/etc/telly/telly.config.toml` or `$HOME/.telly/telly.config.toml` or `telly.config.toml` in the directory that telly is running from. From a70def4d1e45916d32e8f46e319e5bc039a6b15b Mon Sep 17 00:00:00 2001 From: Chaz Larson Date: Mon, 27 Aug 2018 10:33:04 -0500 Subject: [PATCH 052/114] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8927fcc..9d2aafc 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ IPTV proxy for Plex Live written in Golang -## ![#f03c15](https://placehold.it/15/f03c15/000000?text=+) THIS IS A PRERELEASE BETA ![#f03c15](https://placehold.it/15/f03c15/000000?text=+) +## ![#f03c15](https://placehold.it/15/f03c15/000000?text=+) THIS IS A DEVELOPMENT BRANCH ![#f03c15](https://placehold.it/15/f03c15/000000?text=+) It is under active develepment and things may change quickly and dramatically. Please join the discord server if you use this branch and be prepared for some tinkering and breakage. From b74eb4e9950e92a50352f3733a9eda26b818b6e6 Mon Sep 17 00:00:00 2001 From: Chaz Larson Date: Mon, 27 Aug 2018 18:01:12 -0500 Subject: [PATCH 053/114] Clarify which sections are required --- README.md | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9d2aafc..7dcba46 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ It is under active develepment and things may change quickly and dramatically. Here's an example configuration file. **You will need to create this file.** It should be placed in `/etc/telly/telly.config.toml` or `$HOME/.telly/telly.config.toml` or `telly.config.toml` in the directory that telly is running from. ```toml +# THIS SECTION IS REQUIRED ######################################################################## [Discovery] # most likely you won't need to change anything here Device-Auth = "telly123" # These settings are all related to how telly identifies Device-ID = 12345678 # itself to Plex. @@ -22,6 +23,7 @@ Here's an example configuration file. **You will need to create this file.** It Device-Model-Number = "HDTC-2US" SSDP = true +# THIS SECTION IS REQUIRED ######################################################################## [IPTV] Streams = 1 # number of simultaneous streams that the telly virtual DVR will provide # This is often 1, but is set by your iptv provider; for example, @@ -31,24 +33,30 @@ Here's an example configuration file. **You will need to create this file.** It FFMpeg = true # if true, streams are buffered through ffmpeg; ffmpeg must be on your $PATH # if you want to use this with Docker, be sure you use the correct docker image +# THIS SECTION IS REQUIRED ######################################################################## [Log] Level = "info" # Only log messages at or above the given level. [debug, info, warn, error, fatal] Requests = true # Log HTTP requests made to telly +# THIS SECTION IS REQUIRED ######################################################################## [Web] Base-Address = "0.0.0.0:6077" # Set this to the IP address of the machine telly runs on Listen-Address = "0.0.0.0:6077" # this can stay as-is +# THIS SECTION IS OPTIONAL ======================================================================== #[SchedulesDirect] # If you have a Schedules Direct account, fill in details and then # UNCOMMENT THIS SECTION # Username = "" # This is under construction; Vader is the only provider # Password = "" # that works with it fully at this time +# AT LEAST ONE SOURCE IS REQUIRED ################################################################# [[Source]] Name = "" # Name is optional and is used mostly for logging purposes Provider = "Vaders" # named providers currently supported are "Vaders", "area51", "Iris" +# IF YOUR PROVIDER IS NOT ONE OF THE ABOVE, CONFIGURE IT AS A "Custom" PROVIDER; SEE BELOW Username = "YOUR_IPTV_USERNAME" Password = "YOUR_IPTV_PASSWORD" + # THE FOLLOWING KEYS ARE OPTIONAL IN THEORY, REQUIRED IN PRACTICE Filter = "Sports|Premium Movies|United States.*|USA" FilterKey = "group-title" # FilterKey normally defaults to whatever the provider file says is best, # otherwise you must set this. @@ -60,12 +68,24 @@ Here's an example configuration file. **You will need to create this file.** It Provider = "IPTV-EPG" Username = "M3U-Identifier" # From http://iptv-epg.com/[M3U-Identifier].m3u Password = "XML-Identifier" # From http://iptv-epg.com/[XML-Identifier].xml - + # THE FOLLOWING KEYS ARE OPTIONAL HERE; IF YOU"RE USING IPTV-EPG YOU'FE PROBABLY DONE YOUR + # FILTERING THERE ALREADY + # Filter = "" + # FilterKey = "" + # FilterRaw = false + # Sort = "" [[Source]] Provider = "Custom" M3U = "http://myprovider.com/playlist.m3u" # These can be either URLs or fully-qualified paths. EPG = "http://myprovider.com/epg.xml" + # THE FOLLOWING KEYS ARE OPTIONAL IN THEORY, REQUIRED IN PRACTICE + Filter = "Sports|Premium Movies|United States.*|USA" + FilterKey = "group-title" # FilterKey normally defaults to whatever the provider file says is best, + # otherwise you must set this. + FilterRaw = false # FilterRaw will run your regex on the entire line instead of just specific keys. + Sort = "group-title" # Sort will alphabetically sort your channels by the M3U key provided +# END TELLY CONFIG ############################################################################### ``` You only need one source; the ones you are not using should be commented out or deleted. The name and filter-related keys can be used with any of the sources. From 498f4e96434ac43495c242b9c75f866646ef3b7d Mon Sep 17 00:00:00 2001 From: Chaz Larson Date: Mon, 27 Aug 2018 18:07:21 -0500 Subject: [PATCH 054/114] Update README.md --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 7dcba46..0a5a08e 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,8 @@ Here's an example configuration file. **You will need to create this file.** It # Password = "" # that works with it fully at this time # AT LEAST ONE SOURCE IS REQUIRED ################################################################# +# DELETE OR COMMENT OUT SOURCES THAT YOU ARE NOT USING ############################################ +# NONE OF THESE EXAMPLES WORK AS-IS; IF YOU DON'T CHANGE IT, DELETE IT ############################ [[Source]] Name = "" # Name is optional and is used mostly for logging purposes Provider = "Vaders" # named providers currently supported are "Vaders", "area51", "Iris" @@ -87,7 +89,7 @@ Here's an example configuration file. **You will need to create this file.** It Sort = "group-title" # Sort will alphabetically sort your channels by the M3U key provided # END TELLY CONFIG ############################################################################### ``` -You only need one source; the ones you are not using should be commented out or deleted. The name and filter-related keys can be used with any of the sources. +![#f03c15](https://placehold.it/15/f03c15/000000?text=+) You only need one source; the ones you are not using should be commented out or deleted.![#f03c15](https://placehold.it/15/f03c15/000000?text=+) The name and filter-related keys can be used with any of the sources. # FFMpeg From d4c4d48fec97fa8f5f247bfee607cbc40bfec423 Mon Sep 17 00:00:00 2001 From: Chaz Larson Date: Mon, 27 Aug 2018 18:49:41 -0500 Subject: [PATCH 055/114] Update README.md --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 0a5a08e..65fb4ca 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ It is under active develepment and things may change quickly and dramatically. Here's an example configuration file. **You will need to create this file.** It should be placed in `/etc/telly/telly.config.toml` or `$HOME/.telly/telly.config.toml` or `telly.config.toml` in the directory that telly is running from. +> ATTENTION Windows users: be sure that there isn’t a hidden extension on the file. Telly can't read its config file if it's named something like `telly.config.toml.txt`. + ```toml # THIS SECTION IS REQUIRED ######################################################################## [Discovery] # most likely you won't need to change anything here From 9d5f4a3788a166c65dc0a2a864b9933eda588762 Mon Sep 17 00:00:00 2001 From: BillOatman Date: Tue, 28 Aug 2018 14:10:18 -0400 Subject: [PATCH 056/114] Update routes.go Remove the "-bsf:v", "h264_mp4toannexb" to fix HEVC streams. --- routes.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/routes.go b/routes.go index 90e3f13..fed2ff9 100644 --- a/routes.go +++ b/routes.go @@ -189,7 +189,7 @@ func stream(lineup *lineup) gin.HandlerFunc { log.Infoln("Transcoding stream with ffmpeg") - run := exec.Command("ffmpeg", "-re", "-i", channel.providerChannel.Track.URI, "-codec", "copy", "-bsf:v", "h264_mp4toannexb", "-f", "mpegts", "-tune", "zerolatency", "pipe:1") + run := exec.Command("ffmpeg", "-re", "-i", channel.providerChannel.Track.URI, "-codec", "copy", "-f", "mpegts", "-tune", "zerolatency", "pipe:1") ffmpegout, err := run.StdoutPipe() if err != nil { log.WithError(err).Errorln("StdoutPipe Error") From 69197f8b8b80efdb5784a0c5494373e08384a0f6 Mon Sep 17 00:00:00 2001 From: Chaz Larson Date: Tue, 28 Aug 2018 13:18:56 -0500 Subject: [PATCH 057/114] Update to address minor bug in ffmpeg key handling --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 65fb4ca..1378942 100644 --- a/README.md +++ b/README.md @@ -32,8 +32,10 @@ Here's an example configuration file. **You will need to create this file.** It # Vaders provides 5 Starting-Channel = 10000 # When telly assigns channel numbers it will start here XMLTV-Channels = true # if true, any channel numbers specified in your M3U file will be used. - FFMpeg = true # if true, streams are buffered through ffmpeg; ffmpeg must be on your $PATH +# FFMpeg = true # if this is uncommented, streams are buffered through ffmpeg; + # ffmpeg must be installed and on your $PATH # if you want to use this with Docker, be sure you use the correct docker image +# if you DO NOT WANT TO USE FFMPEG leave this uncommented; DO NOT SET IT TO FALSE # THIS SECTION IS REQUIRED ######################################################################## [Log] From 588700118c51c26afe43c44d208cb943aa5d4576 Mon Sep 17 00:00:00 2001 From: Chaz Larson Date: Tue, 28 Aug 2018 13:22:56 -0500 Subject: [PATCH 058/114] Typo --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 1378942..ac97d8a 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ Here's an example configuration file. **You will need to create this file.** It # FFMpeg = true # if this is uncommented, streams are buffered through ffmpeg; # ffmpeg must be installed and on your $PATH # if you want to use this with Docker, be sure you use the correct docker image -# if you DO NOT WANT TO USE FFMPEG leave this uncommented; DO NOT SET IT TO FALSE +# if you DO NOT WANT TO USE FFMPEG leave this commented; DO NOT SET IT TO FALSE # THIS SECTION IS REQUIRED ######################################################################## [Log] From 6351a1788ae7a820faa66520a9a27b0676d779cc Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Tue, 28 Aug 2018 14:24:17 -0700 Subject: [PATCH 059/114] Minor fixes for breaking changes in go.schedulesdirect --- Gopkg.lock | 2 +- lineup.go | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Gopkg.lock b/Gopkg.lock index 91c2ee3..9554b51 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -253,7 +253,7 @@ name = "github.com/tellytv/go.schedulesdirect" packages = ["."] pruneopts = "UT" - revision = "3d6704d3b108deaffd476ad2f27003dc38bf775d" + revision = "34412a2eb0519d921a72e24a3e17e8a335dcdab9" [[projects]] digest = "1:c268acaa4a4d94a467980e5e91452eb61c460145765293dc0aed48e5e9919cc6" diff --git a/lineup.go b/lineup.go index 8030c12..c56410b 100644 --- a/lineup.go +++ b/lineup.go @@ -657,10 +657,10 @@ func MergeSchedulesDirectAndXMLTVProgramme(programme *xmltv.Programme, sdProgram } if !hasXMLTVNS { - seasonNumber := int64(0) - episodeNumber := int64(0) - totalSeasons := int64(0) - totalEpisodes := int64(0) + seasonNumber := 0 + episodeNumber := 0 + totalSeasons := 0 + totalEpisodes := 0 numbersFilled := false for _, meta := range sdProgram.Metadata { From 0d54e8d162acec9967f7a92d1e2f9f264c2bc34c Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Tue, 28 Aug 2018 16:58:25 -0700 Subject: [PATCH 060/114] More fixes for SD --- Gopkg.lock | 2 +- lineup.go | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Gopkg.lock b/Gopkg.lock index 9554b51..ea2ccfa 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -253,7 +253,7 @@ name = "github.com/tellytv/go.schedulesdirect" packages = ["."] pruneopts = "UT" - revision = "34412a2eb0519d921a72e24a3e17e8a335dcdab9" + revision = "49735fc3ed7740fa11ebaafbdeb8ed466fc9e239" [[projects]] digest = "1:c268acaa4a4d94a467980e5e91452eb61c460145765293dc0aed48e5e9919cc6" diff --git a/lineup.go b/lineup.go index c56410b..5e02820 100644 --- a/lineup.go +++ b/lineup.go @@ -610,11 +610,11 @@ func MergeSchedulesDirectAndXMLTVProgramme(programme *xmltv.Programme, sdProgram for _, descriptions := range sdProgram.Descriptions { for _, description := range descriptions { - if description.Description100 != "" { - allDescriptions = append(allDescriptions, description.Description100) + if description.Description != "" { + allDescriptions = append(allDescriptions, description.Description) } - if description.Description1000 != "" { - allDescriptions = append(allDescriptions, description.Description1000) + if description.Description != "" { + allDescriptions = append(allDescriptions, description.Description) } } } From 3138d20f37b9476f92e524fc18a88c8c0cace217 Mon Sep 17 00:00:00 2001 From: Chaz Larson Date: Sat, 1 Sep 2018 10:37:28 -0500 Subject: [PATCH 061/114] Spelling fixes --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ac97d8a..c273663 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ IPTV proxy for Plex Live written in Golang ## ![#f03c15](https://placehold.it/15/f03c15/000000?text=+) THIS IS A DEVELOPMENT BRANCH ![#f03c15](https://placehold.it/15/f03c15/000000?text=+) -It is under active develepment and things may change quickly and dramatically. Please join the discord server if you use this branch and be prepared for some tinkering and breakage. +It is under active development and things may change quickly and dramatically. Please join the discord server if you use this branch and be prepared for some tinkering and breakage. # Configuration @@ -74,7 +74,7 @@ Here's an example configuration file. **You will need to create this file.** It Provider = "IPTV-EPG" Username = "M3U-Identifier" # From http://iptv-epg.com/[M3U-Identifier].m3u Password = "XML-Identifier" # From http://iptv-epg.com/[XML-Identifier].xml - # THE FOLLOWING KEYS ARE OPTIONAL HERE; IF YOU"RE USING IPTV-EPG YOU'FE PROBABLY DONE YOUR + # THE FOLLOWING KEYS ARE OPTIONAL HERE; IF YOU"RE USING IPTV-EPG YOU'VE PROBABLY DONE YOUR # FILTERING THERE ALREADY # Filter = "" # FilterKey = "" From 466c741963e287a1b657b304b6dd3e16b8654476 Mon Sep 17 00:00:00 2001 From: Chaz Larson Date: Sun, 2 Sep 2018 09:50:52 -0500 Subject: [PATCH 062/114] Update README.md Specifically state not to change the magic provider names. --- README.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index c273663..a0fd0ae 100644 --- a/README.md +++ b/README.md @@ -70,8 +70,8 @@ Here's an example configuration file. **You will need to create this file.** It Sort = "group-title" # Sort will alphabetically sort your channels by the M3U key provided [[Source]] - Name = "" - Provider = "IPTV-EPG" + Name = "" # Name is optional and is used mostly for logging purposes + Provider = "IPTV-EPG" # DO NOT CHANGE THIS IF YOU ARE USING THIS PROVIDER Username = "M3U-Identifier" # From http://iptv-epg.com/[M3U-Identifier].m3u Password = "XML-Identifier" # From http://iptv-epg.com/[XML-Identifier].xml # THE FOLLOWING KEYS ARE OPTIONAL HERE; IF YOU"RE USING IPTV-EPG YOU'VE PROBABLY DONE YOUR @@ -82,7 +82,11 @@ Here's an example configuration file. **You will need to create this file.** It # Sort = "" [[Source]] - Provider = "Custom" + Name = "" # Name is optional and is used mostly for logging purposes + Provider = "Custom" # DO NOT CHANGE THIS IF YOU ARE ENTERING URLS OR FILE PATHS + # "Custom" is telly's internal identifier for this 'Provider' + # If you change it to "NAMEOFPROVIDER" telly's reaction will be + # "I don't recognize a provider called 'NAMEOFPROVIDER'." M3U = "http://myprovider.com/playlist.m3u" # These can be either URLs or fully-qualified paths. EPG = "http://myprovider.com/epg.xml" # THE FOLLOWING KEYS ARE OPTIONAL IN THEORY, REQUIRED IN PRACTICE From ac1d42589b4e707a08be94275d01cec2db24d680 Mon Sep 17 00:00:00 2001 From: Chaz Larson Date: Sun, 2 Sep 2018 16:32:47 -0500 Subject: [PATCH 063/114] Note on key fields for multiple instances --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index a0fd0ae..459fac7 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,14 @@ Here's an example configuration file. **You will need to create this file.** It Device-Model-Number = "HDTC-2US" SSDP = true +# Note on running multiple instances of telly +# There are three things that make up a "key" for a given Telly Virtual DVR: +# Device-ID [required], Device-UUID [optional], and port [required] +# When you configure your additional telly instances, change: +# the Device-ID [above] AND +# the Device-UUID [above, if you're entering one] AND +# the port [below in the "Web" section] + # THIS SECTION IS REQUIRED ######################################################################## [IPTV] Streams = 1 # number of simultaneous streams that the telly virtual DVR will provide From bd5dd3a3ed0d0a2ce681bd16ba9e0cc0a8dbed0e Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Sun, 2 Sep 2018 19:27:38 -0700 Subject: [PATCH 064/114] Update .circleci/config.yml to use dep so that we stop having build breakage --- .circleci/config.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 5cfef77..577f736 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -2,14 +2,14 @@ version: 2 jobs: build: docker: - - image: circleci/golang:1.8 + - image: circleci/golang:1 working_directory: /go/src/github.com/tellytv/telly steps: - checkout - - - run: go get -v -t -d ./... + - run: curl https://raw.githubusercontent.com/golang/dep/master/install.sh | sh + # - run: go get -u github.com/alecthomas/gometalinter + # - run: gometalinter --install + - run: dep ensure -vendor-only - run: go test -v ./... - - run: GOOS=linux GOARCH=amd64 go build -o telly_linux_amd64 - - run: GOOS=darwin GOARCH=amd64 go build -o telly_darwin_amd64 - - run: GOOS=windows GOARCH=amd64 go build -o telly_windows_amd64.exe + # - run: gometalinter --config=.gometalinter.json ./... From 632490fdd9bbf482262d80d3cdf2b1c7fe453f2c Mon Sep 17 00:00:00 2001 From: Chaz Larson Date: Tue, 4 Sep 2018 10:45:56 -0500 Subject: [PATCH 065/114] Note about IPTV-EPG field names They don't make sense, but cest la vie. --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index 459fac7..f8e5d23 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,12 @@ Here's an example configuration file. **You will need to create this file.** It Provider = "IPTV-EPG" # DO NOT CHANGE THIS IF YOU ARE USING THIS PROVIDER Username = "M3U-Identifier" # From http://iptv-epg.com/[M3U-Identifier].m3u Password = "XML-Identifier" # From http://iptv-epg.com/[XML-Identifier].xml + # NOTE: THOSE KEY NAMES DO NOT MAKE SENSE ################################## + # For this purpose, IPTV-EPG doesnot have a "username" and "password", HOWEVER, + # telly's scaffolding for a "Named provider" doesm rather than special-casing this provider, + # the username and password are used to hold the two required bits of information. + # THIS IS JUST AN IMPLEMENTATION DETAIL. JUST GO WITH IT. + # NOTE: THOSE KEY NAMES DO NOT MAKE SENSE ################################## # THE FOLLOWING KEYS ARE OPTIONAL HERE; IF YOU"RE USING IPTV-EPG YOU'VE PROBABLY DONE YOUR # FILTERING THERE ALREADY # Filter = "" From aad81cb8a089562a5f3c8f8720a1ff240ecbba69 Mon Sep 17 00:00:00 2001 From: Chaz Larson Date: Tue, 4 Sep 2018 10:51:13 -0500 Subject: [PATCH 066/114] Update README.md --- README.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index f8e5d23..c70acfc 100644 --- a/README.md +++ b/README.md @@ -82,12 +82,13 @@ Here's an example configuration file. **You will need to create this file.** It Provider = "IPTV-EPG" # DO NOT CHANGE THIS IF YOU ARE USING THIS PROVIDER Username = "M3U-Identifier" # From http://iptv-epg.com/[M3U-Identifier].m3u Password = "XML-Identifier" # From http://iptv-epg.com/[XML-Identifier].xml - # NOTE: THOSE KEY NAMES DO NOT MAKE SENSE ################################## - # For this purpose, IPTV-EPG doesnot have a "username" and "password", HOWEVER, - # telly's scaffolding for a "Named provider" doesm rather than special-casing this provider, + # NOTE: THOSE KEY NAMES DO NOT MAKE SENSE FOR THIS PROVIDER ################ + # THIS IS JUST AN IMPLEMENTATION DETAIL. JUST GO WITH IT. + # For this purpose, IPTV-EPG does not have a "username" and "password", HOWEVER, + # telly's scaffolding for a "Named provider" does. Rather than special-casing this provider, # the username and password are used to hold the two required bits of information. # THIS IS JUST AN IMPLEMENTATION DETAIL. JUST GO WITH IT. - # NOTE: THOSE KEY NAMES DO NOT MAKE SENSE ################################## + # NOTE: THOSE KEY NAMES DO NOT MAKE SENSE FOR THIS PROVIDER ################ # THE FOLLOWING KEYS ARE OPTIONAL HERE; IF YOU"RE USING IPTV-EPG YOU'VE PROBABLY DONE YOUR # FILTERING THERE ALREADY # Filter = "" From c62d22a4c05d05118e53f56363a8ffce9076dfde Mon Sep 17 00:00:00 2001 From: Chaz Larson Date: Tue, 4 Sep 2018 13:13:58 -0500 Subject: [PATCH 067/114] Update README.md --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index c70acfc..fbc49e8 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ It is under active development and things may change quickly and dramatically. Here's an example configuration file. **You will need to create this file.** It should be placed in `/etc/telly/telly.config.toml` or `$HOME/.telly/telly.config.toml` or `telly.config.toml` in the directory that telly is running from. +> NOTE "the directory telly is running from" is your CURRENT WORKING DIRECTORY. For example, if telly and its config file file are in `/opt/telly/` and you run telly from your home directory, telly will not find its config file because it will be looking for it in your home directory. If this makes little sense to you, use one of the other two locations OR cd into the directory where telly is located before running it from the command line. + > ATTENTION Windows users: be sure that there isn’t a hidden extension on the file. Telly can't read its config file if it's named something like `telly.config.toml.txt`. ```toml From cec8d730177b15a6d1f60d879d2db493f9de51a3 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Wed, 5 Sep 2018 16:18:37 -0700 Subject: [PATCH 068/114] Fix XMLTV date parsing because Vaders doesnt know how to write dates properly --- VERSION | 2 +- internal/xmltv/xmltv.go | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/VERSION b/VERSION index a3fdef3..dd22e0b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.0.2 +1.1.0.3 diff --git a/internal/xmltv/xmltv.go b/internal/xmltv/xmltv.go index e8a29a4..7ea9d6f 100644 --- a/internal/xmltv/xmltv.go +++ b/internal/xmltv/xmltv.go @@ -5,6 +5,7 @@ import ( "encoding/xml" "fmt" "os" + "strings" "time" "golang.org/x/net/html/charset" @@ -56,6 +57,11 @@ func (p *Date) UnmarshalXML(d *xml.Decoder, start xml.StartElement) (err error) dateFormat = "2006" } + if strings.Contains(content, "|") { + content = strings.Split(content, "|")[0] + dateFormat = "2006" + } + if v, e := time.Parse(dateFormat, content); e != nil { return fmt.Errorf("the type Date field of %s is not a time, value is: %s", start.Name.Local, content) } else { From 774adb0f477892b697dcfa7fda4c8294877ae7f2 Mon Sep 17 00:00:00 2001 From: Chaz Larson Date: Sun, 30 Sep 2018 18:35:45 -0500 Subject: [PATCH 069/114] Update README.md Add a reference to specific version. --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index fbc49e8..42774c2 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ IPTV proxy for Plex Live written in Golang +## This readme refers to version ![#0eaf29](https://placehold.it/15/0eaf29/000000?text=+) 1.1.x ![#0eaf29](https://placehold.it/15/0eaf29/000000?text=+). It does not apply to versions other than that. + ## ![#f03c15](https://placehold.it/15/f03c15/000000?text=+) THIS IS A DEVELOPMENT BRANCH ![#f03c15](https://placehold.it/15/f03c15/000000?text=+) It is under active development and things may change quickly and dramatically. Please join the discord server if you use this branch and be prepared for some tinkering and breakage. From 54e03e85a0524cee04a8ffa5f0bf0b452e1d8e48 Mon Sep 17 00:00:00 2001 From: Ein Auslander Date: Tue, 16 Oct 2018 12:47:03 -0400 Subject: [PATCH 070/114] Update Vaders URL --- internal/providers/vaders.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/providers/vaders.go b/internal/providers/vaders.go index 4344724..f3eef63 100644 --- a/internal/providers/vaders.go +++ b/internal/providers/vaders.go @@ -44,7 +44,7 @@ func (v *vader) Name() string { } func (v *vader) PlaylistURL() string { - return fmt.Sprintf("http://api.vaders.tv/vget?username=%s&password=%s&vod=%t&format=ts", v.BaseConfig.Username, v.BaseConfig.Password, v.BaseConfig.VideoOnDemand) + return fmt.Sprintf("http://vaders.tv/vget?username=%s&password=%s&vod=%t&format=ts", v.BaseConfig.Username, v.BaseConfig.Password, v.BaseConfig.VideoOnDemand) } func (v *vader) EPGURL() string { From 3b9b551eac6ce59d6084b1b5e106b7b3e2cc4a3c Mon Sep 17 00:00:00 2001 From: Chaz Larson Date: Mon, 19 Nov 2018 11:43:46 -0600 Subject: [PATCH 071/114] Update README.md --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 42774c2..bc15539 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ IPTV proxy for Plex Live written in Golang ## This readme refers to version ![#0eaf29](https://placehold.it/15/0eaf29/000000?text=+) 1.1.x ![#0eaf29](https://placehold.it/15/0eaf29/000000?text=+). It does not apply to versions other than that. +The [Wiki](https://github.com/tellytv/telly/wiki) includes walkthroughs for most platforms that go into more detail than listed below: + ## ![#f03c15](https://placehold.it/15/f03c15/000000?text=+) THIS IS A DEVELOPMENT BRANCH ![#f03c15](https://placehold.it/15/f03c15/000000?text=+) It is under active development and things may change quickly and dramatically. Please join the discord server if you use this branch and be prepared for some tinkering and breakage. From 712ef5bd4a3c0885cb466b597f5ddc765ff26e34 Mon Sep 17 00:00:00 2001 From: Chaz Larson Date: Sun, 6 Jan 2019 13:38:22 -0600 Subject: [PATCH 072/114] bump version to 1.1.0.5 --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index dd22e0b..a7c54ab 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.0.3 +1.1.0.5 From baaa9f511cd39b19fc4bca94b208a22151669eef Mon Sep 17 00:00:00 2001 From: Chaz Larson Date: Mon, 21 Jan 2019 12:52:11 -0600 Subject: [PATCH 073/114] Add a log line in a situation that might indicate a missing config file. --- main.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/main.go b/main.go index 2e4d6f2..c74ba70 100644 --- a/main.go +++ b/main.go @@ -180,6 +180,10 @@ func validateConfig() { } } + if !(viper.IsSet("source")) { + log.Warnln("There is no source element in the configuration, the config file is likely missing.") + } + var addrErr error if _, addrErr = net.ResolveTCPAddr("tcp", viper.GetString("web.listenaddress")); addrErr != nil { log.WithError(addrErr).Panic("Error when parsing Listen address, please check the address and try again.") From 3af23271095e0af85f54140cbadd2c8bdcfa4d09 Mon Sep 17 00:00:00 2001 From: Chaz Larson Date: Mon, 21 Jan 2019 12:53:26 -0600 Subject: [PATCH 074/114] Add a couple more targets --- Makefile | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 437f729..732691f 100644 --- a/Makefile +++ b/Makefile @@ -26,13 +26,21 @@ vet: @echo ">> vetting code" @$(GO) vet $(pkgs) +cross: promu + @echo ">> crossbuilding binaries" + @$(PROMU) crossbuild + +tarballs: promu + @echo ">> creating release tarballs" + @$(PROMU) crossbuild tarballs + build: promu @echo ">> building binaries" @$(PROMU) build --prefix $(PREFIX) tarball: promu @echo ">> building release tarball" - @$(PROMU) tarball --prefix $(PREFIX) $(BIN_DIR) + @$(PROMU) tarball $(BIN_DIR) docker: @echo ">> building docker image" From 9557c9627d906d74438d6fd463dfe6107fb0cd34 Mon Sep 17 00:00:00 2001 From: Chaz Larson Date: Mon, 21 Jan 2019 12:54:30 -0600 Subject: [PATCH 075/114] Build using the same mechanism as the makefile so the docker build has a version number Remove redundant build --- Dockerfile | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/Dockerfile b/Dockerfile index fb5a489..42f05bf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,22 +5,23 @@ ADD https://github.com/golang/dep/releases/download/v0.5.0/dep-linux-amd64 /usr/ RUN chmod +x /usr/bin/dep # Install git because gin/yaml needs it -RUN apk update && apk upgrade && apk add git +# Install make for building +RUN apk update && apk upgrade && apk add git make # Copy the code from the host and compile it WORKDIR $GOPATH/src/github.com/tellytv/telly COPY Gopkg.toml Gopkg.lock ./ RUN dep ensure --vendor-only COPY . ./ -RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix nocgo -o /app . -# install ca root certificates + listen on 0.0.0.0 + build -RUN apk add --no-cache ca-certificates \ - && find . -type f -print0 | xargs -0 sed -i 's/"listen", "localhost/"listen", "0.0.0.0/g' \ - && CGO_ENABLED=0 GOOS=linux go install -ldflags '-w -s -extldflags "-static"' +# Build the executable using promu since that builds in the version info +# copy the resulting executable to the root under the name "app" +RUN make promu && make build && mv ./telly /app FROM scratch +# Original: copy from the builder image above: COPY --from=builder /app ./ -COPY --from=builder /etc/ssl/certs/ /etc/ssl/certs/ EXPOSE 6077 ENTRYPOINT ["./app"] + + From e1455f069ef77b28a153b203efefa3194935f4c2 Mon Sep 17 00:00:00 2001 From: Chaz Larson Date: Wed, 23 Jan 2019 11:32:52 -0600 Subject: [PATCH 076/114] Update README.md --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index bc15539..bcbe9c4 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ IPTV proxy for Plex Live written in Golang +Please refer to the [Wiki](https://github.com/tellytv/telly/wiki) for the most current documentation. + ## This readme refers to version ![#0eaf29](https://placehold.it/15/0eaf29/000000?text=+) 1.1.x ![#0eaf29](https://placehold.it/15/0eaf29/000000?text=+). It does not apply to versions other than that. The [Wiki](https://github.com/tellytv/telly/wiki) includes walkthroughs for most platforms that go into more detail than listed below: From 0d28df61af33f5f0779427d8ad24d62a719e478c Mon Sep 17 00:00:00 2001 From: Chaz Larson Date: Mon, 18 Feb 2019 15:57:40 -0600 Subject: [PATCH 077/114] Update tnt.go --- internal/providers/tnt.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/providers/tnt.go b/internal/providers/tnt.go index 3960706..b5c08b3 100644 --- a/internal/providers/tnt.go +++ b/internal/providers/tnt.go @@ -1,4 +1,7 @@ package providers -// M3U: http://thesepeanutz.xyz:2052/get.php?username=xxx&password=xxx&type=m3uplus&output=ts +// M3U: http://thesepeanutz.xyz:2052/get.php?username=xxx&password=xxx&type=m3u_plus&output=ts // XMLTV: http://thesepeanutz.xyz:2052/xmltv.php?username=xxx&password=xxx + +// EPG: http://tntcloud.xyz:2052/xmltv.php?username=XXX&password=XXX +// M3U: http://tntcloud.xyz:2052/get.php?username=XXX&password=XXX&type=m3u_plus&output=ts From 3c289e8f8b53d5ab4b3d8bfc225acd6175a5e5ee Mon Sep 17 00:00:00 2001 From: Aman Gupta Date: Mon, 6 May 2019 10:42:01 -0700 Subject: [PATCH 078/114] hdhr device ids are alpha numeric --- README.md | 2 +- main.go | 4 ++-- utils.go | 3 +-- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index bcbe9c4..faf2875 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ Here's an example configuration file. **You will need to create this file.** It # THIS SECTION IS REQUIRED ######################################################################## [Discovery] # most likely you won't need to change anything here Device-Auth = "telly123" # These settings are all related to how telly identifies - Device-ID = 12345678 # itself to Plex. + Device-ID = "12345678" # itself to Plex. Device-UUID = "" Device-Firmware-Name = "hdhomeruntc_atsc" Device-Firmware-Version = "20150826" diff --git a/main.go b/main.go index c74ba70..01b92b4 100644 --- a/main.go +++ b/main.go @@ -56,7 +56,7 @@ var ( func main() { // Discovery flags - flag.Int("discovery.device-id", 12345678, "8 digits used to uniquely identify the device. $(TELLY_DISCOVERY_DEVICE_ID)") + flag.String("discovery.device-id", "12345678", "8 alpha-numeric characters used to uniquely identify the device. $(TELLY_DISCOVERY_DEVICE_ID)") flag.String("discovery.device-friendly-name", "telly", "Name exposed via discovery. Useful if you are running two instances of telly and want to differentiate between them $(TELLY_DISCOVERY_DEVICE_FRIENDLY_NAME)") flag.String("discovery.device-auth", "telly123", "Only change this if you know what you're doing $(TELLY_DISCOVERY_DEVICE_AUTH)") flag.String("discovery.device-manufacturer", "Silicondust", "Manufacturer exposed via discovery. $(TELLY_DISCOVERY_DEVICE_MANUFACTURER)") @@ -154,7 +154,7 @@ func main() { validateConfig() viper.Set("discovery.device-friendly-name", fmt.Sprintf("HDHomerun (%s)", viper.GetString("discovery.device-friendly-name"))) - viper.Set("discovery.device-uuid", fmt.Sprintf("%d-AE2A-4E54-BBC9-33AF7D5D6A92", viper.GetInt("discovery.device-id"))) + viper.Set("discovery.device-uuid", fmt.Sprintf("%d-AE2A-4E54-BBC9-33AF7D5D6A92", viper.GetString("discovery.device-id"))) if log.Level == logrus.DebugLevel { js, jsErr := json.MarshalIndent(viper.AllSettings(), "", " ") diff --git a/utils.go b/utils.go index 2bddd71..68e756b 100644 --- a/utils.go +++ b/utils.go @@ -3,7 +3,6 @@ package main import ( "fmt" "net" - "strconv" "github.com/spf13/viper" ) @@ -24,7 +23,7 @@ func getDiscoveryData() DiscoveryData { FirmwareName: viper.GetString("discovery.device-firmware-name"), TunerCount: viper.GetInt("iptv.streams"), FirmwareVersion: viper.GetString("discovery.device-firmware-version"), - DeviceID: strconv.Itoa(viper.GetInt("discovery.device-id")), + DeviceID: viper.GetString("discovery.device-id"), DeviceAuth: viper.GetString("discovery.device-auth"), BaseURL: fmt.Sprintf("http://%s", viper.GetString("web.base-address")), LineupURL: fmt.Sprintf("http://%s/lineup.json", viper.GetString("web.base-address")), From 475e91b735d5646cd6375f9e4b333ca12f42dfd7 Mon Sep 17 00:00:00 2001 From: Aman Gupta Date: Mon, 6 May 2019 10:32:08 -0700 Subject: [PATCH 079/114] remove HDHomerun prefix from friendly name --- main.go | 1 - 1 file changed, 1 deletion(-) diff --git a/main.go b/main.go index 01b92b4..6b9fa93 100644 --- a/main.go +++ b/main.go @@ -153,7 +153,6 @@ func main() { validateConfig() - viper.Set("discovery.device-friendly-name", fmt.Sprintf("HDHomerun (%s)", viper.GetString("discovery.device-friendly-name"))) viper.Set("discovery.device-uuid", fmt.Sprintf("%d-AE2A-4E54-BBC9-33AF7D5D6A92", viper.GetString("discovery.device-id"))) if log.Level == logrus.DebugLevel { From 1a1e28c3b6d1f55ea1c1a155fc28701e44b8d132 Mon Sep 17 00:00:00 2001 From: Chaz Larson Date: Mon, 6 May 2019 15:05:22 -0500 Subject: [PATCH 080/114] Removed references to the late, lamented Vader Streams. --- README.md | 10 +-- internal/providers/main.go | 2 - internal/providers/vaders.go | 140 ----------------------------------- 3 files changed, 5 insertions(+), 147 deletions(-) delete mode 100644 internal/providers/vaders.go diff --git a/README.md b/README.md index bcbe9c4..46243f0 100644 --- a/README.md +++ b/README.md @@ -63,23 +63,23 @@ Here's an example configuration file. **You will need to create this file.** It Base-Address = "0.0.0.0:6077" # Set this to the IP address of the machine telly runs on Listen-Address = "0.0.0.0:6077" # this can stay as-is -# THIS SECTION IS OPTIONAL ======================================================================== +# THIS SECTION IS NOT USEFUL ====================================================================== #[SchedulesDirect] # If you have a Schedules Direct account, fill in details and then # UNCOMMENT THIS SECTION -# Username = "" # This is under construction; Vader is the only provider -# Password = "" # that works with it fully at this time +# Username = "" # This is under construction; no provider +# Password = "" # works with it at this time # AT LEAST ONE SOURCE IS REQUIRED ################################################################# # DELETE OR COMMENT OUT SOURCES THAT YOU ARE NOT USING ############################################ # NONE OF THESE EXAMPLES WORK AS-IS; IF YOU DON'T CHANGE IT, DELETE IT ############################ [[Source]] Name = "" # Name is optional and is used mostly for logging purposes - Provider = "Vaders" # named providers currently supported are "Vaders", "area51", "Iris" + Provider = "Iris" # named providers currently supported are "area51" and "Iris" # IF YOUR PROVIDER IS NOT ONE OF THE ABOVE, CONFIGURE IT AS A "Custom" PROVIDER; SEE BELOW Username = "YOUR_IPTV_USERNAME" Password = "YOUR_IPTV_PASSWORD" # THE FOLLOWING KEYS ARE OPTIONAL IN THEORY, REQUIRED IN PRACTICE - Filter = "Sports|Premium Movies|United States.*|USA" + Filter = "YOUR|FILTER|*REGEX" FilterKey = "group-title" # FilterKey normally defaults to whatever the provider file says is best, # otherwise you must set this. FilterRaw = false # FilterRaw will run your regex on the entire line instead of just specific keys. diff --git a/internal/providers/main.go b/internal/providers/main.go index 41c199b..3f7475f 100644 --- a/internal/providers/main.go +++ b/internal/providers/main.go @@ -48,8 +48,6 @@ type Configuration struct { func (i *Configuration) GetProvider() (Provider, error) { switch strings.ToLower(i.Provider) { - case "vaders": - return newVaders(i) case "iptv-epg", "iptvepg": return newIPTVEPG(i) case "iris", "iristv": diff --git a/internal/providers/vaders.go b/internal/providers/vaders.go deleted file mode 100644 index f3eef63..0000000 --- a/internal/providers/vaders.go +++ /dev/null @@ -1,140 +0,0 @@ -package providers - -import ( - "encoding/base64" - "encoding/json" - "fmt" - "regexp" - "strconv" - "strings" - - log "github.com/sirupsen/logrus" - m3u "github.com/tellytv/telly/internal/m3uplus" - "github.com/tellytv/telly/internal/xmltv" -) - -// This regex matches and extracts the following URLs. -// http://vapi.vaders.tv/play/dvr/${start}/123.ts?duration=3600&token= -// http://vapi.vaders.tv/play/123.ts?token= -// http://vapi.vaders.tv/play/vod/123.mp4.m3u8?token= -// http://vapi.vaders.tv/play/vod/123.avi.m3u8?token= -// http://vapi.vaders.tv/play/vod/123.mkv.m3u8?token= -var vadersURL = regexp.MustCompile(`/(vod/|dvr/\${start}/)?(\d+).(ts|.*.m3u8)\?(duration=\d+&)?token=`).FindAllStringSubmatch - -// M3U: http://api.vaders.tv/vget?username=xxx&password=xxx&format=ts -// XMLTV: http://vaders.tv/p2.xml - -type vader struct { - BaseConfig Configuration - - Token string `json:"-"` -} - -func newVaders(config *Configuration) (Provider, error) { - tok, tokErr := json.Marshal(config) - if tokErr != nil { - return nil, tokErr - } - - return &vader{*config, base64.StdEncoding.EncodeToString(tok)}, nil -} - -func (v *vader) Name() string { - return "Vaders.tv" -} - -func (v *vader) PlaylistURL() string { - return fmt.Sprintf("http://vaders.tv/vget?username=%s&password=%s&vod=%t&format=ts", v.BaseConfig.Username, v.BaseConfig.Password, v.BaseConfig.VideoOnDemand) -} - -func (v *vader) EPGURL() string { - return "http://vaders.tv/p2.xml.gz" -} - -// ParseTrack matches the provided M3U track an XMLTV channel and returns a ProviderChannel. -func (v *vader) ParseTrack(track m3u.Track, channelMap map[string]xmltv.Channel) (*ProviderChannel, error) { - streamURL := vadersURL(track.URI, -1)[0] - - vod := strings.Contains(streamURL[1], "vod") - - if v.BaseConfig.VideoOnDemand == false && vod { - return nil, nil - } - - channelID, channelIDErr := strconv.Atoi(streamURL[2]) - if channelIDErr != nil { - return nil, channelIDErr - } - - nameVal := track.Tags["tvg-name"] - if v.BaseConfig.NameKey != "" { - nameVal = track.Tags[v.BaseConfig.NameKey] - } - - logoVal := track.Tags["tvg-logo"] - if v.BaseConfig.LogoKey != "" { - logoVal = track.Tags[v.BaseConfig.LogoKey] - } - - pChannel := &ProviderChannel{ - Name: nameVal, - Logo: logoVal, - StreamURL: track.URI, - StreamID: channelID, - HD: strings.Contains(strings.ToLower(track.Tags["tvg-name"]), "hd"), - StreamFormat: streamURL[3], - Track: track, - OnDemand: vod, - } - - if xmlChan, ok := channelMap[track.Tags["tvg-id"]]; ok { - pChannel.EPGMatch = track.Tags["tvg-id"] - pChannel.EPGChannel = &xmlChan - - for _, displayName := range xmlChan.DisplayNames { - if channelNumberRegex(displayName.Value) { - if chanNum, chanNumErr := strconv.Atoi(displayName.Value); chanNumErr == nil { - pChannel.Number = chanNum - } - } - } - } - - favoriteTag := "tvg-id" - - if v.BaseConfig.FavoriteTag != "" { - favoriteTag = v.BaseConfig.FavoriteTag - } - - if _, ok := track.Tags[favoriteTag]; !ok { - log.Panicf("The specified favorite tag (%s) doesn't exist on the track with URL %s", favoriteTag, track.URI) - return nil, nil - } - - pChannel.Favorite = contains(v.BaseConfig.Favorites, track.Tags[favoriteTag]) - - return pChannel, nil -} - -func (v *vader) ProcessProgramme(programme xmltv.Programme) *xmltv.Programme { - isNew := false - for idx, title := range programme.Titles { - isNew = strings.HasSuffix(title.Value, " [New!]") - programme.Titles[idx].Value = strings.Replace(title.Value, " [New!]", "", -1) - } - - if isNew { - elm := xmltv.ElementPresent(true) - programme.New = &elm - } - - return &programme -} - -func (v *vader) Configuration() Configuration { - return v.BaseConfig -} - -func (v *vader) RegexKey() string { - return "group-title" -} From d580a94ec2a10cb5913a0eafd14e2adf1115ead2 Mon Sep 17 00:00:00 2001 From: Aman Gupta Date: Tue, 7 May 2019 01:53:19 -0700 Subject: [PATCH 081/114] set content type for ffmpeg stream --- routes.go | 1 + 1 file changed, 1 insertion(+) diff --git a/routes.go b/routes.go index fed2ff9..2292818 100644 --- a/routes.go +++ b/routes.go @@ -215,6 +215,7 @@ func stream(lineup *lineup) gin.HandlerFunc { }() continueStream := true + c.Header("Content-Type", `video/mpeg; codecs="avc1.4D401E"`) c.Stream(func(w io.Writer) bool { defer func() { From cd748dc396ceae1bb23b2781715ab7a04129c1d7 Mon Sep 17 00:00:00 2001 From: Aman Gupta Date: Tue, 7 May 2019 01:54:31 -0700 Subject: [PATCH 082/114] remove unused ffmpeg option -tune is an encoding option. when passing -codec copy, no encoding is happening so that option is not used. since there is no encoding or decoding happening, it is incorrect to state that ffmpeg is transcoding. --- routes.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/routes.go b/routes.go index fed2ff9..eb29a39 100644 --- a/routes.go +++ b/routes.go @@ -187,9 +187,9 @@ func stream(lineup *lineup) gin.HandlerFunc { return } - log.Infoln("Transcoding stream with ffmpeg") + log.Infoln("Remuxing stream with ffmpeg") - run := exec.Command("ffmpeg", "-re", "-i", channel.providerChannel.Track.URI, "-codec", "copy", "-f", "mpegts", "-tune", "zerolatency", "pipe:1") + run := exec.Command("ffmpeg", "-re", "-i", channel.providerChannel.Track.URI, "-codec", "copy", "-f", "mpegts", "pipe:1") ffmpegout, err := run.StdoutPipe() if err != nil { log.WithError(err).Errorln("StdoutPipe Error") From c66579a246eff832ac4f6d3d87b8c78f454eec3b Mon Sep 17 00:00:00 2001 From: Aman Gupta Date: Tue, 7 May 2019 12:47:07 -0700 Subject: [PATCH 083/114] replace dep with go mod --- .circleci/config.yml | 2 - Dockerfile | 6 - Gopkg.lock | 364 ------------------------------------------- Gopkg.toml | 54 ------- Makefile | 13 +- go.mod | 41 +++++ go.sum | 75 +++++++++ 7 files changed, 123 insertions(+), 432 deletions(-) delete mode 100644 Gopkg.lock delete mode 100644 Gopkg.toml create mode 100644 go.mod create mode 100644 go.sum diff --git a/.circleci/config.yml b/.circleci/config.yml index 577f736..df99044 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -7,9 +7,7 @@ jobs: working_directory: /go/src/github.com/tellytv/telly steps: - checkout - - run: curl https://raw.githubusercontent.com/golang/dep/master/install.sh | sh # - run: go get -u github.com/alecthomas/gometalinter # - run: gometalinter --install - - run: dep ensure -vendor-only - run: go test -v ./... # - run: gometalinter --config=.gometalinter.json ./... diff --git a/Dockerfile b/Dockerfile index 42f05bf..6f62dc9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,17 +1,11 @@ FROM golang:alpine as builder -# Download and install the latest release of dep -ADD https://github.com/golang/dep/releases/download/v0.5.0/dep-linux-amd64 /usr/bin/dep -RUN chmod +x /usr/bin/dep - # Install git because gin/yaml needs it # Install make for building RUN apk update && apk upgrade && apk add git make # Copy the code from the host and compile it WORKDIR $GOPATH/src/github.com/tellytv/telly -COPY Gopkg.toml Gopkg.lock ./ -RUN dep ensure --vendor-only COPY . ./ # Build the executable using promu since that builds in the version info diff --git a/Gopkg.lock b/Gopkg.lock deleted file mode 100644 index ea2ccfa..0000000 --- a/Gopkg.lock +++ /dev/null @@ -1,364 +0,0 @@ -# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. - - -[[projects]] - branch = "master" - digest = "1:d6afaeed1502aa28e80a4ed0981d570ad91b2579193404256ce672ed0a609e0d" - name = "github.com/beorn7/perks" - packages = ["quantile"] - pruneopts = "UT" - revision = "3a771d992973f24aa725d07868b467d1ddfceafb" - -[[projects]] - digest = "1:abeb38ade3f32a92943e5be54f55ed6d6e3b6602761d74b4aab4c9dd45c18abd" - name = "github.com/fsnotify/fsnotify" - packages = ["."] - pruneopts = "UT" - revision = "c2828203cd70a50dcccfb2761f8b1f8ceef9a8e9" - version = "v1.4.7" - -[[projects]] - digest = "1:2b59aca2665ff804f6606c8829eaee133ddd3aefbc841014660d961b0034f888" - name = "github.com/gin-contrib/cors" - packages = ["."] - pruneopts = "UT" - revision = "cf4846e6a636a76237a28d9286f163c132e841bc" - version = "v1.2" - -[[projects]] - branch = "master" - digest = "1:36fe9527deed01d2a317617e59304eb2c4ce9f8a24115bcc5c2e37b3aee5bae4" - name = "github.com/gin-contrib/sse" - packages = ["."] - pruneopts = "UT" - revision = "22d885f9ecc78bf4ee5d72b937e4bbcdc58e8cae" - -[[projects]] - digest = "1:489e108f21464371ebf9cb5c30b1eceb07c6dd772dff073919267493dd9d04ea" - name = "github.com/gin-gonic/gin" - packages = [ - ".", - "binding", - "render", - ] - pruneopts = "UT" - revision = "d459835d2b077e44f7c9b453505ee29881d5d12d" - version = "v1.2" - -[[projects]] - digest = "1:35534a9283f212bdc542697dfca3c2700f2b2b1771e409476f08701b44c1709a" - name = "github.com/gobuffalo/packr" - packages = ["."] - pruneopts = "UT" - revision = "1aab5672bd385f2a7da18bffa961912e7642ea79" - version = "v1.13.2" - -[[projects]] - digest = "1:15042ad3498153684d09f393bbaec6b216c8eec6d61f63dff711de7d64ed8861" - name = "github.com/golang/protobuf" - packages = ["proto"] - pruneopts = "UT" - revision = "b4deda0973fb4c70b50d226b1af49f3da59f5265" - version = "v1.1.0" - -[[projects]] - branch = "master" - digest = "1:a361611b8c8c75a1091f00027767f7779b29cb37c456a71b8f2604c88057ab40" - name = "github.com/hashicorp/hcl" - packages = [ - ".", - "hcl/ast", - "hcl/parser", - "hcl/printer", - "hcl/scanner", - "hcl/strconv", - "hcl/token", - "json/parser", - "json/scanner", - "json/token", - ] - pruneopts = "UT" - revision = "ef8a98b0bbce4a65b5aa4c368430a80ddc533168" - -[[projects]] - branch = "master" - digest = "1:8f57afa9ef1d9205094e9d89b9cb4ecb3123f342c4eb0053d7631181b511e6e4" - name = "github.com/koron/go-ssdp" - packages = ["."] - pruneopts = "UT" - revision = "4a0ed625a78b6858dc8d3a55fb7728968b712122" - -[[projects]] - digest = "1:ca955a9cd5b50b0f43d2cc3aeb35c951473eeca41b34eb67507f1dbcc0542394" - name = "github.com/kr/pretty" - packages = ["."] - pruneopts = "UT" - revision = "73f6ac0b30a98e433b289500d779f50c1a6f0712" - version = "v0.1.0" - -[[projects]] - digest = "1:15b5cc79aad436d47019f814fde81a10221c740dc8ddf769221a65097fb6c2e9" - name = "github.com/kr/text" - packages = ["."] - pruneopts = "UT" - revision = "e2ffdb16a802fe2bb95e2e35ff34f0e53aeef34f" - version = "v0.1.0" - -[[projects]] - digest = "1:c568d7727aa262c32bdf8a3f7db83614f7af0ed661474b24588de635c20024c7" - name = "github.com/magiconair/properties" - packages = ["."] - pruneopts = "UT" - revision = "c2353362d570a7bfa228149c62842019201cfb71" - version = "v1.8.0" - -[[projects]] - digest = "1:fa610f9fe6a93f4a75e64c83673dfff9bf1a34bbb21e6102021b6bc7850834a3" - name = "github.com/mattn/go-isatty" - packages = ["."] - pruneopts = "UT" - revision = "57fdcb988a5c543893cc61bce354a6e24ab70022" - -[[projects]] - digest = "1:ff5ebae34cfbf047d505ee150de27e60570e8c394b3b8fdbb720ff6ac71985fc" - name = "github.com/matttproud/golang_protobuf_extensions" - packages = ["pbutil"] - pruneopts = "UT" - revision = "c12348ce28de40eed0136aa2b644d0ee0650e56c" - version = "v1.0.1" - -[[projects]] - branch = "master" - digest = "1:5ab79470a1d0fb19b041a624415612f8236b3c06070161a910562f2b2d064355" - name = "github.com/mitchellh/mapstructure" - packages = ["."] - pruneopts = "UT" - revision = "f15292f7a699fcc1a38a80977f80a046874ba8ac" - -[[projects]] - digest = "1:95741de3af260a92cc5c7f3f3061e85273f5a81b5db20d4bd68da74bd521675e" - name = "github.com/pelletier/go-toml" - packages = ["."] - pruneopts = "UT" - revision = "c01d1270ff3e442a8a57cddc1c92dc1138598194" - version = "v1.2.0" - -[[projects]] - digest = "1:40e195917a951a8bf867cd05de2a46aaf1806c50cf92eebf4c16f78cd196f747" - name = "github.com/pkg/errors" - packages = ["."] - pruneopts = "UT" - revision = "645ef00459ed84a119197bfb8d8205042c6df63d" - version = "v0.8.0" - -[[projects]] - digest = "1:d14a5f4bfecf017cb780bdde1b6483e5deb87e12c332544d2c430eda58734bcb" - name = "github.com/prometheus/client_golang" - packages = [ - "prometheus", - "prometheus/promhttp", - ] - pruneopts = "UT" - revision = "c5b7fccd204277076155f10851dad72b76a49317" - version = "v0.8.0" - -[[projects]] - branch = "master" - digest = "1:2d5cd61daa5565187e1d96bae64dbbc6080dacf741448e9629c64fd93203b0d4" - name = "github.com/prometheus/client_model" - packages = ["go"] - pruneopts = "UT" - revision = "5c3871d89910bfb32f5fcab2aa4b9ec68e65a99f" - -[[projects]] - branch = "master" - digest = "1:9b2b68310a7555601c28980840f4d6966f8ff5443e11f4f78d227dbf73205132" - name = "github.com/prometheus/common" - packages = [ - "expfmt", - "internal/bitbucket.org/ww/goautoneg", - "model", - "version", - ] - pruneopts = "UT" - revision = "c7de2306084e37d54b8be01f3541a8464345e9a5" - -[[projects]] - branch = "master" - digest = "1:8c49953a1414305f2ff5465147ee576dd705487c35b15918fcd4efdc0cb7a290" - name = "github.com/prometheus/procfs" - packages = [ - ".", - "internal/util", - "nfs", - "xfs", - ] - pruneopts = "UT" - revision = "05ee40e3a273f7245e8777337fc7b46e533a9a92" - -[[projects]] - digest = "1:d867dfa6751c8d7a435821ad3b736310c2ed68945d05b50fb9d23aee0540c8cc" - name = "github.com/sirupsen/logrus" - packages = ["."] - pruneopts = "UT" - revision = "3e01752db0189b9157070a0e1668a620f9a85da2" - version = "v1.0.6" - -[[projects]] - digest = "1:bd1ae00087d17c5a748660b8e89e1043e1e5479d0fea743352cda2f8dd8c4f84" - name = "github.com/spf13/afero" - packages = [ - ".", - "mem", - ] - pruneopts = "UT" - revision = "787d034dfe70e44075ccc060d346146ef53270ad" - version = "v1.1.1" - -[[projects]] - digest = "1:516e71bed754268937f57d4ecb190e01958452336fa73dbac880894164e91c1f" - name = "github.com/spf13/cast" - packages = ["."] - pruneopts = "UT" - revision = "8965335b8c7107321228e3e3702cab9832751bac" - version = "v1.2.0" - -[[projects]] - branch = "master" - digest = "1:8a020f916b23ff574845789daee6818daf8d25a4852419aae3f0b12378ba432a" - name = "github.com/spf13/jwalterweatherman" - packages = ["."] - pruneopts = "UT" - revision = "14d3d4c518341bea657dd8a226f5121c0ff8c9f2" - -[[projects]] - digest = "1:dab83a1bbc7ad3d7a6ba1a1cc1760f25ac38cdf7d96a5cdd55cd915a4f5ceaf9" - name = "github.com/spf13/pflag" - packages = ["."] - pruneopts = "UT" - revision = "9a97c102cda95a86cec2345a6f09f55a939babf5" - version = "v1.0.2" - -[[projects]] - digest = "1:4fc8a61287ccfb4286e1ca5ad2ce3b0b301d746053bf44ac38cf34e40ae10372" - name = "github.com/spf13/viper" - packages = ["."] - pruneopts = "UT" - revision = "907c19d40d9a6c9bb55f040ff4ae45271a4754b9" - version = "v1.1.0" - -[[projects]] - branch = "master" - digest = "1:2f6be3c7ff8cc65d5f6b35c2acd928aed1386fc31dc11483045b393660698244" - name = "github.com/tellytv/go.schedulesdirect" - packages = ["."] - pruneopts = "UT" - revision = "49735fc3ed7740fa11ebaafbdeb8ed466fc9e239" - -[[projects]] - digest = "1:c268acaa4a4d94a467980e5e91452eb61c460145765293dc0aed48e5e9919cc6" - name = "github.com/ugorji/go" - packages = ["codec"] - pruneopts = "UT" - revision = "c88ee250d0221a57af388746f5cf03768c21d6e2" - -[[projects]] - branch = "master" - digest = "1:3f3a05ae0b95893d90b9b3b5afdb79a9b3d96e4e36e099d841ae602e4aca0da8" - name = "golang.org/x/crypto" - packages = ["ssh/terminal"] - pruneopts = "UT" - revision = "de0752318171da717af4ce24d0a2e8626afaeb11" - -[[projects]] - branch = "master" - digest = "1:2d073118530c09a068ae1c47b054b5bdf75f625621658ecb642bcad7e65eb66a" - name = "golang.org/x/net" - packages = [ - "bpf", - "html", - "html/atom", - "html/charset", - "internal/iana", - "internal/socket", - "ipv4", - ] - pruneopts = "UT" - revision = "c39426892332e1bb5ec0a434a079bf82f5d30c54" - -[[projects]] - branch = "master" - digest = "1:a60cae5be8993938498243605b120290533a5208fd5cac81c932afbad3642fb0" - name = "golang.org/x/sys" - packages = [ - "unix", - "windows", - ] - pruneopts = "UT" - revision = "98c5dad5d1a0e8a73845ecc8897d0bd56586511d" - -[[projects]] - digest = "1:4392fcf42d5cf0e3ff78c96b2acf8223d49e4fdc53eb77c99d2f8dfe4680e006" - name = "golang.org/x/text" - packages = [ - "encoding", - "encoding/charmap", - "encoding/htmlindex", - "encoding/internal", - "encoding/internal/identifier", - "encoding/japanese", - "encoding/korean", - "encoding/simplifiedchinese", - "encoding/traditionalchinese", - "encoding/unicode", - "internal/gen", - "internal/tag", - "internal/triegen", - "internal/ucd", - "internal/utf8internal", - "language", - "runes", - "transform", - "unicode/cldr", - "unicode/norm", - ] - pruneopts = "UT" - revision = "f21a4dfb5e38f5895301dc265a8def02365cc3d0" - version = "v0.3.0" - -[[projects]] - digest = "1:1b4724d3c8125f6044925f02b485b74bfec9905cbf579d95aafd1a6c8f8447d3" - name = "gopkg.in/go-playground/validator.v8" - packages = ["."] - pruneopts = "UT" - revision = "5f57d2222ad794d0dffb07e664ea05e2ee07d60c" - version = "v8.18.1" - -[[projects]] - digest = "1:cacb98d52c60c337c2ce95a7af83ba0313a93ce5e73fa9e99a96aff70776b9d3" - name = "gopkg.in/yaml.v2" - packages = ["."] - pruneopts = "UT" - revision = "a5b47d31c556af34a302ce5d659e6fea44d90de0" - -[solve-meta] - analyzer-name = "dep" - analyzer-version = 1 - input-imports = [ - "github.com/gin-contrib/cors", - "github.com/gin-gonic/gin", - "github.com/gobuffalo/packr", - "github.com/koron/go-ssdp", - "github.com/kr/pretty", - "github.com/mitchellh/mapstructure", - "github.com/prometheus/client_golang/prometheus", - "github.com/prometheus/client_golang/prometheus/promhttp", - "github.com/prometheus/common/version", - "github.com/sirupsen/logrus", - "github.com/spf13/pflag", - "github.com/spf13/viper", - "github.com/tellytv/go.schedulesdirect", - "golang.org/x/net/html/charset", - ] - solver-name = "gps-cdcl" - solver-version = 1 diff --git a/Gopkg.toml b/Gopkg.toml deleted file mode 100644 index 546090b..0000000 --- a/Gopkg.toml +++ /dev/null @@ -1,54 +0,0 @@ -# Gopkg.toml example -# -# Refer to https://golang.github.io/dep/docs/Gopkg.toml.html -# for detailed Gopkg.toml documentation. -# -# required = ["github.com/user/thing/cmd/thing"] -# ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"] -# -# [[constraint]] -# name = "github.com/user/project" -# version = "1.0.0" -# -# [[constraint]] -# name = "github.com/user/project2" -# branch = "dev" -# source = "github.com/myfork/project2" -# -# [[override]] -# name = "github.com/x/y" -# version = "2.4.0" -# -# [prune] -# non-go = false -# go-tests = true -# unused-packages = true - - -[[constraint]] - name = "github.com/gin-gonic/gin" - version = "1.2.0" - -[[constraint]] - name = "github.com/koron/go-ssdp" - branch = "master" - -[[constraint]] - branch = "master" - name = "github.com/mitchellh/mapstructure" - -[[constraint]] - name = "github.com/prometheus/client_golang" - version = "0.8.0" - -[[constraint]] - branch = "master" - name = "github.com/prometheus/common" - -[[constraint]] - name = "github.com/sirupsen/logrus" - version = "1.0.6" - -[prune] - go-tests = true - unused-packages = true diff --git a/Makefile b/Makefile index 732691f..e0e0f39 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ -GO := GO15VENDOREXPERIMENT=1 go +GO := go +GOPATH ?= $(HOME)/go PROMU := $(GOPATH)/bin/promu -pkgs = $(shell $(GO) list ./... | grep -v /vendor/) PREFIX ?= $(shell pwd) BIN_DIR ?= $(shell pwd) @@ -16,15 +16,15 @@ style: test: @echo ">> running tests" - @$(GO) test -short $(pkgs) + @$(GO) test -short ./... format: @echo ">> formatting code" - @$(GO) fmt $(pkgs) + @$(GO) fmt ./... vet: @echo ">> vetting code" - @$(GO) vet $(pkgs) + @$(GO) vet ./... cross: promu @echo ">> crossbuilding binaries" @@ -47,7 +47,8 @@ docker: @docker build -t "$(DOCKER_IMAGE_NAME):$(DOCKER_IMAGE_TAG)" . promu: - @GOOS=$(shell uname -s | tr A-Z a-z) \ + @GO111MODULE=off \ + GOOS=$(shell uname -s | tr A-Z a-z) \ GOARCH=$(subst x86_64,amd64,$(patsubst i%86,386,$(shell uname -m))) \ $(GO) get -u github.com/prometheus/promu diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d212fdb --- /dev/null +++ b/go.mod @@ -0,0 +1,41 @@ +module github.com/tellytv/telly + +go 1.12 + +require ( + github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973 + github.com/fsnotify/fsnotify v1.4.7 + github.com/gin-contrib/cors v0.0.0-20170318125340-cf4846e6a636 + github.com/gin-contrib/sse v0.0.0-20170109093832-22d885f9ecc7 + github.com/gin-gonic/gin v0.0.0-20170702092826-d459835d2b07 + github.com/gobuffalo/packr v1.13.2 + github.com/golang/protobuf v1.1.0 + github.com/hashicorp/hcl v0.0.0-20180404174102-ef8a98b0bbce + github.com/koron/go-ssdp v0.0.0-20180514024734-4a0ed625a78b + github.com/kr/pretty v0.1.0 + github.com/kr/text v0.1.0 + github.com/magiconair/properties v1.8.0 + github.com/mattn/go-isatty v0.0.0-20170307163044-57fdcb988a5c + github.com/matttproud/golang_protobuf_extensions v1.0.1 + github.com/mitchellh/mapstructure v0.0.0-20180715050151-f15292f7a699 + github.com/pelletier/go-toml v1.2.0 + github.com/pkg/errors v0.8.0 + github.com/prometheus/client_golang v0.8.0 + github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910 + github.com/prometheus/common v0.0.0-20180801064454-c7de2306084e + github.com/prometheus/procfs v0.0.0-20180725123919-05ee40e3a273 + github.com/sirupsen/logrus v1.0.6 + github.com/spf13/afero v1.1.1 + github.com/spf13/cast v1.2.0 + github.com/spf13/jwalterweatherman v0.0.0-20180814060501-14d3d4c51834 + github.com/spf13/pflag v1.0.2 + github.com/spf13/viper v1.1.0 + github.com/tellytv/go.schedulesdirect v0.0.0-20180828235349-49735fc3ed77 + github.com/ugorji/go v0.0.0-20170215201144-c88ee250d022 + golang.org/x/crypto v0.0.0-20180808211826-de0752318171 + golang.org/x/net v0.0.0-20180811021610-c39426892332 + golang.org/x/sys v0.0.0-20180810173357-98c5dad5d1a0 + golang.org/x/text v0.3.0 + gopkg.in/go-playground/validator.v8 v8.18.1 + gopkg.in/yaml.v2 v2.0.0-20160928153709-a5b47d31c556 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4b6d2cc --- /dev/null +++ b/go.sum @@ -0,0 +1,75 @@ +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973 h1:xJ4a3vCFaGF/jqvzLMYoU8P317H5OQ+Via4RmuPwCS0= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/gin-contrib/cors v0.0.0-20170318125340-cf4846e6a636 h1:oGgJA7DJphAc81EMHZ+2G7Ai2xyg5eoq7bbqzCsiWFc= +github.com/gin-contrib/cors v0.0.0-20170318125340-cf4846e6a636/go.mod h1:cw+u9IsAkC16e42NtYYVCLsHYXE98nB3M7Dr9mLSeH4= +github.com/gin-contrib/sse v0.0.0-20170109093832-22d885f9ecc7 h1:AzN37oI0cOS+cougNAV9szl6CVoj2RYwzS3DpUQNtlY= +github.com/gin-contrib/sse v0.0.0-20170109093832-22d885f9ecc7/go.mod h1:VJ0WA2NBN22VlZ2dKZQPAPnyWw5XTlK1KymzLKsr59s= +github.com/gin-gonic/gin v0.0.0-20170702092826-d459835d2b07 h1:cZPJWzd2oNeoS0oJM2TlN9rl0OnCgUr10gC8Q4mH+6M= +github.com/gin-gonic/gin v0.0.0-20170702092826-d459835d2b07/go.mod h1:7cKuhb5qV2ggCFctp2fJQ+ErvciLZrIeoOSOm6mUr7Y= +github.com/gobuffalo/packr v1.13.2 h1:fQmeSiOMhl+4U+da7VmX2AjdcCaSOi5IvnqsSXdKYmQ= +github.com/gobuffalo/packr v1.13.2/go.mod h1:qdqw8AgJyKw60qj56fnEBiS9fIqqCaP/vWJQvR4Jcss= +github.com/golang/protobuf v1.1.0 h1:0iH4Ffd/meGoXqF2lSAhZHt8X+cPgkfn/cb6Cce5Vpc= +github.com/golang/protobuf v1.1.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/hashicorp/hcl v0.0.0-20180404174102-ef8a98b0bbce h1:xdsDDbiBDQTKASoGEZ+pEmF1OnWuu8AQ9I8iNbHNeno= +github.com/hashicorp/hcl v0.0.0-20180404174102-ef8a98b0bbce/go.mod h1:oZtUIOe8dh44I2q6ScRibXws4Ajl+d+nod3AaR9vL5w= +github.com/koron/go-ssdp v0.0.0-20180514024734-4a0ed625a78b h1:wxtKgYHEncAU00muMD06dzLiahtGM1eouRNOzVV7tdQ= +github.com/koron/go-ssdp v0.0.0-20180514024734-4a0ed625a78b/go.mod h1:5Ky9EC2xfoUKUor0Hjgi2BJhCSXJfMOFlmyYrVKGQMk= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/magiconair/properties v1.8.0 h1:LLgXmsheXeRoUOBOjtwPQCWIYqM/LU1ayDtDePerRcY= +github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= +github.com/markbates/grift v1.0.1/go.mod h1:aC7s7OfCOzc2WCafmTm7wI3cfGFA/8opYhdTGlIAmmo= +github.com/mattn/go-isatty v0.0.0-20170307163044-57fdcb988a5c h1:AHfQR/s6GNi92TOh+kfGworqDvTxj2rMsS+Hca87nck= +github.com/mattn/go-isatty v0.0.0-20170307163044-57fdcb988a5c/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= +github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/mitchellh/mapstructure v0.0.0-20180715050151-f15292f7a699 h1:KXZJFdun9knAVAR8tg/aHJEr5DgtcbqyvzacK+CDCaI= +github.com/mitchellh/mapstructure v0.0.0-20180715050151-f15292f7a699/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/pelletier/go-toml v1.2.0 h1:T5zMGML61Wp+FlcbWjRDT7yAxhJNAiPPLOFECq181zc= +github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= +github.com/pkg/errors v0.8.0 h1:WdK/asTD0HN+q6hsWO3/vpuAkAr+tw6aNJNDFFf0+qw= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v0.8.0 h1:1921Yw9Gc3iSc4VQh3PIoOqgPCZS7G/4xQNVUp8Mda8= +github.com/prometheus/client_golang v0.8.0/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910 h1:idejC8f05m9MGOsuEi1ATq9shN03HrxNkD/luQvxCv8= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/common v0.0.0-20180801064454-c7de2306084e h1:n/3MEhJQjQxrOUCzh1Y3Re6aJUUWRp2M9+Oc3eVn/54= +github.com/prometheus/common v0.0.0-20180801064454-c7de2306084e/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= +github.com/prometheus/procfs v0.0.0-20180725123919-05ee40e3a273 h1:agujYaXJSxSo18YNX3jzl+4G6Bstwt+kqv47GS12uL0= +github.com/prometheus/procfs v0.0.0-20180725123919-05ee40e3a273/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/sirupsen/logrus v1.0.6 h1:hcP1GmhGigz/O7h1WVUM5KklBp1JoNS9FggWKdj/j3s= +github.com/sirupsen/logrus v1.0.6/go.mod h1:pMByvHTf9Beacp5x1UXfOR9xyW/9antXMhjMPG0dEzc= +github.com/spf13/afero v1.1.1 h1:Lt3ihYMlE+lreX1GS4Qw4ZsNpYQLxIXKBTEOXm3nt6I= +github.com/spf13/afero v1.1.1/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= +github.com/spf13/cast v1.2.0 h1:HHl1DSRbEQN2i8tJmtS6ViPyHx35+p51amrdsiTCrkg= +github.com/spf13/cast v1.2.0/go.mod h1:r2rcYCSwa1IExKTDiTfzaxqT2FNHs8hODu4LnUfgKEg= +github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= +github.com/spf13/jwalterweatherman v0.0.0-20180814060501-14d3d4c51834 h1:kJI9pPzfsULT/72wy7mxkRQZPtKWgFdCA2RTGZ4v8/E= +github.com/spf13/jwalterweatherman v0.0.0-20180814060501-14d3d4c51834/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= +github.com/spf13/pflag v1.0.2 h1:Fy0orTDgHdbnzHcsOgfCN4LtHf0ec3wwtiwJqwvf3Gc= +github.com/spf13/pflag v1.0.2/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/viper v1.1.0 h1:V7OZpY8i3C1x/pDmU0zNNlfVoDz112fSYvtWMjjS3f4= +github.com/spf13/viper v1.1.0/go.mod h1:A8kyI5cUJhb8N+3pkfONlcEcZbueH6nhAm0Fq7SrnBM= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/tellytv/go.schedulesdirect v0.0.0-20180828235349-49735fc3ed77 h1:eZWUcYXkpSpcwKyc/GXRMv+l4pGf47wQp5QCplO/66o= +github.com/tellytv/go.schedulesdirect v0.0.0-20180828235349-49735fc3ed77/go.mod h1:pBZcxidsU285nwpDZ3NQIONgAyOo4wiUoOutTMu7KU4= +github.com/ugorji/go v0.0.0-20170215201144-c88ee250d022 h1:wIYK3i9zY6ZBcWw4GFvoPVwtb45iEm8KyOVmDhSLvsE= +github.com/ugorji/go v0.0.0-20170215201144-c88ee250d022/go.mod h1:hnLbHMwcvSihnDhEfx2/BzKp2xb0Y+ErdfYcrs9tkJQ= +golang.org/x/crypto v0.0.0-20180808211826-de0752318171 h1:vYogbvSFj2YXcjQxFHu/rASSOt9sLytpCaSkiwQ135I= +golang.org/x/crypto v0.0.0-20180808211826-de0752318171/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/net v0.0.0-20180811021610-c39426892332 h1:efGso+ep0DjyCBJPjvoz0HI6UldX4Md2F1rZFe1ir0E= +golang.org/x/net v0.0.0-20180811021610-c39426892332/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180810173357-98c5dad5d1a0 h1:8H8QZJ30plJyIVj60H3lr8TZGIq2Fh3Cyrs/ZNg1foU= +golang.org/x/sys v0.0.0-20180810173357-98c5dad5d1a0/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +gopkg.in/go-playground/validator.v8 v8.18.1 h1:F8SLY5Vqesjs1nI1EL4qmF1PQZ1sitsmq0rPYXLyfGU= +gopkg.in/go-playground/validator.v8 v8.18.1/go.mod h1:RX2a/7Ha8BgOhfk7j780h4/u/RRjR0eouCJSH80/M2Y= +gopkg.in/yaml.v2 v2.0.0-20160928153709-a5b47d31c556 h1:hKXbLW5oaJoQgs8KrzTLdF4PoHi+0oQPgea9TNtvE3E= +gopkg.in/yaml.v2 v2.0.0-20160928153709-a5b47d31c556/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= From 7f224fcbd02125dc5b48f3d58661bbac25635e53 Mon Sep 17 00:00:00 2001 From: Aman Gupta Date: Tue, 7 May 2019 12:47:17 -0700 Subject: [PATCH 084/114] update dependencies --- go.mod | 34 +++++++++++++++--------- go.sum | 83 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 13 deletions(-) diff --git a/go.mod b/go.mod index d212fdb..264f7bf 100644 --- a/go.mod +++ b/go.mod @@ -3,39 +3,47 @@ module github.com/tellytv/telly go 1.12 require ( - github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973 + github.com/beorn7/perks v1.0.0 github.com/fsnotify/fsnotify v1.4.7 github.com/gin-contrib/cors v0.0.0-20170318125340-cf4846e6a636 github.com/gin-contrib/sse v0.0.0-20170109093832-22d885f9ecc7 github.com/gin-gonic/gin v0.0.0-20170702092826-d459835d2b07 + github.com/go-logfmt/logfmt v0.4.0 // indirect github.com/gobuffalo/packr v1.13.2 - github.com/golang/protobuf v1.1.0 + github.com/gogo/protobuf v1.2.1 // indirect + github.com/golang/protobuf v1.3.1 github.com/hashicorp/hcl v0.0.0-20180404174102-ef8a98b0bbce + github.com/kisielk/errcheck v1.2.0 // indirect + github.com/konsorten/go-windows-terminal-sequences v1.0.2 // indirect github.com/koron/go-ssdp v0.0.0-20180514024734-4a0ed625a78b github.com/kr/pretty v0.1.0 + github.com/kr/pty v1.1.4 // indirect github.com/kr/text v0.1.0 github.com/magiconair/properties v1.8.0 github.com/mattn/go-isatty v0.0.0-20170307163044-57fdcb988a5c github.com/matttproud/golang_protobuf_extensions v1.0.1 github.com/mitchellh/mapstructure v0.0.0-20180715050151-f15292f7a699 github.com/pelletier/go-toml v1.2.0 - github.com/pkg/errors v0.8.0 - github.com/prometheus/client_golang v0.8.0 - github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910 - github.com/prometheus/common v0.0.0-20180801064454-c7de2306084e - github.com/prometheus/procfs v0.0.0-20180725123919-05ee40e3a273 - github.com/sirupsen/logrus v1.0.6 + github.com/pkg/errors v0.8.1 + github.com/prometheus/client_golang v0.9.2 + github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90 + github.com/prometheus/common v0.3.0 + github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084 + github.com/prometheus/promu v0.3.0 // indirect + github.com/sirupsen/logrus v1.4.1 github.com/spf13/afero v1.1.1 github.com/spf13/cast v1.2.0 github.com/spf13/jwalterweatherman v0.0.0-20180814060501-14d3d4c51834 github.com/spf13/pflag v1.0.2 github.com/spf13/viper v1.1.0 + github.com/stretchr/objx v0.2.0 // indirect github.com/tellytv/go.schedulesdirect v0.0.0-20180828235349-49735fc3ed77 github.com/ugorji/go v0.0.0-20170215201144-c88ee250d022 - golang.org/x/crypto v0.0.0-20180808211826-de0752318171 - golang.org/x/net v0.0.0-20180811021610-c39426892332 - golang.org/x/sys v0.0.0-20180810173357-98c5dad5d1a0 - golang.org/x/text v0.3.0 + golang.org/x/crypto v0.0.0-20190506204251-e1dfcc566284 + golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c + golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b + golang.org/x/text v0.3.2 + golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c // indirect gopkg.in/go-playground/validator.v8 v8.18.1 - gopkg.in/yaml.v2 v2.0.0-20160928153709-a5b47d31c556 + gopkg.in/yaml.v2 v2.2.2 ) diff --git a/go.sum b/go.sum index 4b6d2cc..70c4ada 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,13 @@ +github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc h1:cAKDfWh5VpdgMhJosfJnn5/FoN2SRZ4p7fJNX58YPaU= +github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf h1:qet1QNfXsQxTZqLG4oE62mJzwPIB8+Tee4RNCL9ulrY= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973 h1:xJ4a3vCFaGF/jqvzLMYoU8P317H5OQ+Via4RmuPwCS0= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/beorn7/perks v1.0.0 h1:HWo1m869IqiPhD389kmkxeTalrjNbbJTC8LXupb+sl0= +github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/gin-contrib/cors v0.0.0-20170318125340-cf4846e6a636 h1:oGgJA7DJphAc81EMHZ+2G7Ai2xyg5eoq7bbqzCsiWFc= @@ -9,16 +16,35 @@ github.com/gin-contrib/sse v0.0.0-20170109093832-22d885f9ecc7 h1:AzN37oI0cOS+cou github.com/gin-contrib/sse v0.0.0-20170109093832-22d885f9ecc7/go.mod h1:VJ0WA2NBN22VlZ2dKZQPAPnyWw5XTlK1KymzLKsr59s= github.com/gin-gonic/gin v0.0.0-20170702092826-d459835d2b07 h1:cZPJWzd2oNeoS0oJM2TlN9rl0OnCgUr10gC8Q4mH+6M= github.com/gin-gonic/gin v0.0.0-20170702092826-d459835d2b07/go.mod h1:7cKuhb5qV2ggCFctp2fJQ+ErvciLZrIeoOSOm6mUr7Y= +github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/gobuffalo/packr v1.13.2 h1:fQmeSiOMhl+4U+da7VmX2AjdcCaSOi5IvnqsSXdKYmQ= github.com/gobuffalo/packr v1.13.2/go.mod h1:qdqw8AgJyKw60qj56fnEBiS9fIqqCaP/vWJQvR4Jcss= +github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= github.com/golang/protobuf v1.1.0 h1:0iH4Ffd/meGoXqF2lSAhZHt8X+cPgkfn/cb6Cce5Vpc= github.com/golang/protobuf v1.1.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1 h1:YF8+flBXS5eO826T4nzqPrxfhQThhXl0YzfuUPu4SBg= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/hashicorp/hcl v0.0.0-20180404174102-ef8a98b0bbce h1:xdsDDbiBDQTKASoGEZ+pEmF1OnWuu8AQ9I8iNbHNeno= github.com/hashicorp/hcl v0.0.0-20180404174102-ef8a98b0bbce/go.mod h1:oZtUIOe8dh44I2q6ScRibXws4Ajl+d+nod3AaR9vL5w= +github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= +github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/koron/go-ssdp v0.0.0-20180514024734-4a0ed625a78b h1:wxtKgYHEncAU00muMD06dzLiahtGM1eouRNOzVV7tdQ= github.com/koron/go-ssdp v0.0.0-20180514024734-4a0ed625a78b/go.mod h1:5Ky9EC2xfoUKUor0Hjgi2BJhCSXJfMOFlmyYrVKGQMk= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/pty v1.1.4/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/magiconair/properties v1.8.0 h1:LLgXmsheXeRoUOBOjtwPQCWIYqM/LU1ayDtDePerRcY= github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= @@ -29,21 +55,43 @@ github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0j github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/mitchellh/mapstructure v0.0.0-20180715050151-f15292f7a699 h1:KXZJFdun9knAVAR8tg/aHJEr5DgtcbqyvzacK+CDCaI= github.com/mitchellh/mapstructure v0.0.0-20180715050151-f15292f7a699/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/pelletier/go-toml v1.2.0 h1:T5zMGML61Wp+FlcbWjRDT7yAxhJNAiPPLOFECq181zc= github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= github.com/pkg/errors v0.8.0 h1:WdK/asTD0HN+q6hsWO3/vpuAkAr+tw6aNJNDFFf0+qw= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v0.8.0 h1:1921Yw9Gc3iSc4VQh3PIoOqgPCZS7G/4xQNVUp8Mda8= github.com/prometheus/client_golang v0.8.0/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v0.9.2 h1:awm861/B8OKDd2I/6o1dy3ra4BamzKhYOiGItCeZ740= +github.com/prometheus/client_golang v0.9.2/go.mod h1:OsXs2jCmiKlQ1lTBmv21f2mNfw4xf/QclQDMrYNZzcM= github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910 h1:idejC8f05m9MGOsuEi1ATq9shN03HrxNkD/luQvxCv8= github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90 h1:S/YWwWx/RA8rT8tKFRuGUZhuA90OyIBpPCXkcbwU8DE= +github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/common v0.0.0-20180801064454-c7de2306084e h1:n/3MEhJQjQxrOUCzh1Y3Re6aJUUWRp2M9+Oc3eVn/54= github.com/prometheus/common v0.0.0-20180801064454-c7de2306084e/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= +github.com/prometheus/common v0.0.0-20181126121408-4724e9255275/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= +github.com/prometheus/common v0.2.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.3.0 h1:taZ4h8Tkxv2kNyoSctBvfXEHmBmxrwmIidZTIaHons4= +github.com/prometheus/common v0.3.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= github.com/prometheus/procfs v0.0.0-20180725123919-05ee40e3a273 h1:agujYaXJSxSo18YNX3jzl+4G6Bstwt+kqv47GS12uL0= github.com/prometheus/procfs v0.0.0-20180725123919-05ee40e3a273/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.0-20181204211112-1dc9a6cbc91a/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.0-20190209105433-f8d8b3f739bd/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084 h1:sofwID9zm4tzrgykg80hfFph1mryUeLRsUfoocVVmRY= +github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/promu v0.3.0 h1:ecIZ1FIjQ+PAneA6g0KpUa7FDimozQtDjzI2rW0Pmh0= +github.com/prometheus/promu v0.3.0/go.mod h1:+NXvSS3J95z3ZmFZP0DXUt+g/I6zyK1CQoBJKkjzX4k= github.com/sirupsen/logrus v1.0.6 h1:hcP1GmhGigz/O7h1WVUM5KklBp1JoNS9FggWKdj/j3s= github.com/sirupsen/logrus v1.0.6/go.mod h1:pMByvHTf9Beacp5x1UXfOR9xyW/9antXMhjMPG0dEzc= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sirupsen/logrus v1.4.1 h1:GL2rEmy6nsikmW0r8opw9JIRScdMF5hA8cOYLH7In1k= +github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= github.com/spf13/afero v1.1.1 h1:Lt3ihYMlE+lreX1GS4Qw4ZsNpYQLxIXKBTEOXm3nt6I= github.com/spf13/afero v1.1.1/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= github.com/spf13/cast v1.2.0 h1:HHl1DSRbEQN2i8tJmtS6ViPyHx35+p51amrdsiTCrkg= @@ -55,21 +103,56 @@ github.com/spf13/pflag v1.0.2 h1:Fy0orTDgHdbnzHcsOgfCN4LtHf0ec3wwtiwJqwvf3Gc= github.com/spf13/pflag v1.0.2/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= github.com/spf13/viper v1.1.0 h1:V7OZpY8i3C1x/pDmU0zNNlfVoDz112fSYvtWMjjS3f4= github.com/spf13/viper v1.1.0/go.mod h1:A8kyI5cUJhb8N+3pkfONlcEcZbueH6nhAm0Fq7SrnBM= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/tellytv/go.schedulesdirect v0.0.0-20180828235349-49735fc3ed77 h1:eZWUcYXkpSpcwKyc/GXRMv+l4pGf47wQp5QCplO/66o= github.com/tellytv/go.schedulesdirect v0.0.0-20180828235349-49735fc3ed77/go.mod h1:pBZcxidsU285nwpDZ3NQIONgAyOo4wiUoOutTMu7KU4= github.com/ugorji/go v0.0.0-20170215201144-c88ee250d022 h1:wIYK3i9zY6ZBcWw4GFvoPVwtb45iEm8KyOVmDhSLvsE= github.com/ugorji/go v0.0.0-20170215201144-c88ee250d022/go.mod h1:hnLbHMwcvSihnDhEfx2/BzKp2xb0Y+ErdfYcrs9tkJQ= golang.org/x/crypto v0.0.0-20180808211826-de0752318171 h1:vYogbvSFj2YXcjQxFHu/rASSOt9sLytpCaSkiwQ135I= golang.org/x/crypto v0.0.0-20180808211826-de0752318171/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190506204251-e1dfcc566284/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/net v0.0.0-20180811021610-c39426892332 h1:efGso+ep0DjyCBJPjvoz0HI6UldX4Md2F1rZFe1ir0E= golang.org/x/net v0.0.0-20180811021610-c39426892332/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c h1:uOCk1iQW6Vc18bnC13MfzScl+wdKBmM9Y9kU7Z83/lw= +golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180810173357-98c5dad5d1a0 h1:8H8QZJ30plJyIVj60H3lr8TZGIq2Fh3Cyrs/ZNg1foU= golang.org/x/sys v0.0.0-20180810173357-98c5dad5d1a0/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b h1:ag/x1USPSsqHud38I9BAC88qdNLDHHtQ4mlgQIZPPNA= +golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +gopkg.in/alecthomas/kingpin.v2 v2.2.6 h1:jMFz6MfLP0/4fUyZle81rXUoxOBFi19VUFKVDOQfozc= +gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/go-playground/validator.v8 v8.18.1 h1:F8SLY5Vqesjs1nI1EL4qmF1PQZ1sitsmq0rPYXLyfGU= gopkg.in/go-playground/validator.v8 v8.18.1/go.mod h1:RX2a/7Ha8BgOhfk7j780h4/u/RRjR0eouCJSH80/M2Y= gopkg.in/yaml.v2 v2.0.0-20160928153709-a5b47d31c556 h1:hKXbLW5oaJoQgs8KrzTLdF4PoHi+0oQPgea9TNtvE3E= gopkg.in/yaml.v2 v2.0.0-20160928153709-a5b47d31c556/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= From c5ccca93b16e8709421eeb76d13682cb0c2021ab Mon Sep 17 00:00:00 2001 From: Aman Gupta Date: Tue, 7 May 2019 12:47:36 -0700 Subject: [PATCH 085/114] fix test failure --- main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.go b/main.go index 6b9fa93..4112fa0 100644 --- a/main.go +++ b/main.go @@ -153,7 +153,7 @@ func main() { validateConfig() - viper.Set("discovery.device-uuid", fmt.Sprintf("%d-AE2A-4E54-BBC9-33AF7D5D6A92", viper.GetString("discovery.device-id"))) + viper.Set("discovery.device-uuid", fmt.Sprintf("%s-AE2A-4E54-BBC9-33AF7D5D6A92", viper.GetString("discovery.device-id"))) if log.Level == logrus.DebugLevel { js, jsErr := json.MarshalIndent(viper.AllSettings(), "", " ") From 308e3d1265c9fc244295c8f1649c950196c85142 Mon Sep 17 00:00:00 2001 From: Aman Gupta Date: Tue, 7 May 2019 13:06:38 -0700 Subject: [PATCH 086/114] GOPATH not required anymore --- .circleci/config.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index df99044..684859d 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -4,7 +4,6 @@ jobs: docker: - image: circleci/golang:1 - working_directory: /go/src/github.com/tellytv/telly steps: - checkout # - run: go get -u github.com/alecthomas/gometalinter From 61d35e691669c36b5d5d45c2f7cfe12a5b36ec40 Mon Sep 17 00:00:00 2001 From: Aman Gupta Date: Thu, 9 May 2019 00:32:16 -0700 Subject: [PATCH 087/114] fix version number configuration to be compatible with go modules --- .promu.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.promu.yml b/.promu.yml index eeb4831..dbc7186 100644 --- a/.promu.yml +++ b/.promu.yml @@ -3,11 +3,11 @@ repository: build: flags: -a -tags netgo ldflags: | - -X {{repoPath}}/vendor/github.com/prometheus/common/version.Version={{.Version}} - -X {{repoPath}}/vendor/github.com/prometheus/common/version.Revision={{.Revision}} - -X {{repoPath}}/vendor/github.com/prometheus/common/version.Branch={{.Branch}} - -X {{repoPath}}/vendor/github.com/prometheus/common/version.BuildUser={{user}}@{{host}} - -X {{repoPath}}/vendor/github.com/prometheus/common/version.BuildDate={{date "20060102-15:04:05"}} + -X github.com/prometheus/common/version.Version={{.Version}} + -X github.com/prometheus/common/version.Revision={{.Revision}} + -X github.com/prometheus/common/version.Branch={{.Branch}} + -X github.com/prometheus/common/version.BuildUser={{user}}@{{host}} + -X github.com/prometheus/common/version.BuildDate={{date "20060102-15:04:05"}} tarball: files: - LICENSE From dafac1f21a0f5397511965d9f0a2ae930d51fe95 Mon Sep 17 00:00:00 2001 From: Chaz Larson Date: Thu, 9 May 2019 09:42:17 -0500 Subject: [PATCH 088/114] Bump version number to 1.1.0.6 --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index a7c54ab..6d75618 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.0.5 +1.1.0.6 From 48b965a350d8607c40503311b68668be205e9bb7 Mon Sep 17 00:00:00 2001 From: Chaz Larson Date: Thu, 9 May 2019 10:09:06 -0500 Subject: [PATCH 089/114] updated go.mod, go.sum --- go.mod | 14 +++++++----- go.sum | 70 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 5 deletions(-) diff --git a/go.mod b/go.mod index 264f7bf..4bbf884 100644 --- a/go.mod +++ b/go.mod @@ -9,17 +9,21 @@ require ( github.com/gin-contrib/sse v0.0.0-20170109093832-22d885f9ecc7 github.com/gin-gonic/gin v0.0.0-20170702092826-d459835d2b07 github.com/go-logfmt/logfmt v0.4.0 // indirect - github.com/gobuffalo/packr v1.13.2 + github.com/gobuffalo/depgen v0.1.1 // indirect + github.com/gobuffalo/genny v0.1.1 // indirect + github.com/gobuffalo/gogen v0.1.1 // indirect + github.com/gobuffalo/packr v1.25.0 github.com/gogo/protobuf v1.2.1 // indirect github.com/golang/protobuf v1.3.1 github.com/hashicorp/hcl v0.0.0-20180404174102-ef8a98b0bbce + github.com/karrick/godirwalk v1.10.0 // indirect github.com/kisielk/errcheck v1.2.0 // indirect - github.com/konsorten/go-windows-terminal-sequences v1.0.2 // indirect github.com/koron/go-ssdp v0.0.0-20180514024734-4a0ed625a78b github.com/kr/pretty v0.1.0 github.com/kr/pty v1.1.4 // indirect github.com/kr/text v0.1.0 github.com/magiconair/properties v1.8.0 + github.com/markbates/grift v1.0.1 // indirect github.com/mattn/go-isatty v0.0.0-20170307163044-57fdcb988a5c github.com/matttproud/golang_protobuf_extensions v1.0.1 github.com/mitchellh/mapstructure v0.0.0-20180715050151-f15292f7a699 @@ -34,16 +38,16 @@ require ( github.com/spf13/afero v1.1.1 github.com/spf13/cast v1.2.0 github.com/spf13/jwalterweatherman v0.0.0-20180814060501-14d3d4c51834 - github.com/spf13/pflag v1.0.2 + github.com/spf13/pflag v1.0.3 github.com/spf13/viper v1.1.0 github.com/stretchr/objx v0.2.0 // indirect github.com/tellytv/go.schedulesdirect v0.0.0-20180828235349-49735fc3ed77 github.com/ugorji/go v0.0.0-20170215201144-c88ee250d022 golang.org/x/crypto v0.0.0-20190506204251-e1dfcc566284 golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c - golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b + golang.org/x/sys v0.0.0-20190509141414-a5b02f93d862 golang.org/x/text v0.3.2 - golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c // indirect + golang.org/x/tools v0.0.0-20190509014725-d996b19ee77c // indirect gopkg.in/go-playground/validator.v8 v8.18.1 gopkg.in/yaml.v2 v2.2.2 ) diff --git a/go.sum b/go.sum index 70c4ada..568feb3 100644 --- a/go.sum +++ b/go.sum @@ -20,8 +20,44 @@ github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2 github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/gobuffalo/attrs v0.0.0-20190224210810-a9411de4debd/go.mod h1:4duuawTqi2wkkpB4ePgWMaai6/Kc6WEz83bhFwpHzj0= +github.com/gobuffalo/depgen v0.0.0-20190329151759-d478694a28d3/go.mod h1:3STtPUQYuzV0gBVOY3vy6CfMm/ljR4pABfrTeHNLHUY= +github.com/gobuffalo/depgen v0.1.0/go.mod h1:+ifsuy7fhi15RWncXQQKjWS9JPkdah5sZvtHc2RXGlg= +github.com/gobuffalo/depgen v0.1.1/go.mod h1:65EOv3g7CMe4kc8J1Ds+l2bjcwrWKGXkE4/vpRRLPWY= +github.com/gobuffalo/envy v1.6.15/go.mod h1:n7DRkBerg/aorDM8kbduw5dN3oXGswK5liaSCx4T5NI= +github.com/gobuffalo/envy v1.7.0 h1:GlXgaiBkmrYMHco6t4j7SacKO4XUjvh5pwXh0f4uxXU= +github.com/gobuffalo/envy v1.7.0/go.mod h1:n7DRkBerg/aorDM8kbduw5dN3oXGswK5liaSCx4T5NI= +github.com/gobuffalo/flect v0.1.0/go.mod h1:d2ehjJqGOH/Kjqcoz+F7jHTBbmDb38yXA598Hb50EGs= +github.com/gobuffalo/flect v0.1.1/go.mod h1:8JCgGVbRjJhVgD6399mQr4fx5rRfGKVzFjbj6RE/9UI= +github.com/gobuffalo/flect v0.1.3/go.mod h1:8JCgGVbRjJhVgD6399mQr4fx5rRfGKVzFjbj6RE/9UI= +github.com/gobuffalo/genny v0.0.0-20190329151137-27723ad26ef9/go.mod h1:rWs4Z12d1Zbf19rlsn0nurr75KqhYp52EAGGxTbBhNk= +github.com/gobuffalo/genny v0.0.0-20190403191548-3ca520ef0d9e/go.mod h1:80lIj3kVJWwOrXWWMRzzdhW3DsrdjILVil/SFKBzF28= +github.com/gobuffalo/genny v0.1.0/go.mod h1:XidbUqzak3lHdS//TPu2OgiFB+51Ur5f7CSnXZ/JDvo= +github.com/gobuffalo/genny v0.1.1 h1:iQ0D6SpNXIxu52WESsD+KoQ7af2e3nCfnSBoSF/hKe0= +github.com/gobuffalo/genny v0.1.1/go.mod h1:5TExbEyY48pfunL4QSXxlDOmdsD44RRq4mVZ0Ex28Xk= +github.com/gobuffalo/gitgen v0.0.0-20190315122116-cc086187d211/go.mod h1:vEHJk/E9DmhejeLeNt7UVvlSGv3ziL+djtTr3yyzcOw= +github.com/gobuffalo/gogen v0.0.0-20190315121717-8f38393713f5/go.mod h1:V9QVDIxsgKNZs6L2IYiGR8datgMhB577vzTDqypH360= +github.com/gobuffalo/gogen v0.1.0/go.mod h1:8NTelM5qd8RZ15VjQTFkAW6qOMx5wBbW4dSCS3BY8gg= +github.com/gobuffalo/gogen v0.1.1 h1:dLg+zb+uOyd/mKeQUYIbwbNmfRsr9hd/WtYWepmayhI= +github.com/gobuffalo/gogen v0.1.1/go.mod h1:y8iBtmHmGc4qa3urIyo1shvOD8JftTtfcKi+71xfDNE= +github.com/gobuffalo/logger v0.0.0-20190315122211-86e12af44bc2 h1:8thhT+kUJMTMy3HlX4+y9Da+BNJck+p109tqqKp7WDs= +github.com/gobuffalo/logger v0.0.0-20190315122211-86e12af44bc2/go.mod h1:QdxcLw541hSGtBnhUc4gaNIXRjiDppFGaDqzbrBd3v8= +github.com/gobuffalo/mapi v1.0.1/go.mod h1:4VAGh89y6rVOvm5A8fKFxYG+wIW6LO1FMTG9hnKStFc= +github.com/gobuffalo/mapi v1.0.2 h1:fq9WcL1BYrm36SzK6+aAnZ8hcp+SrmnDyAxhNx8dvJk= +github.com/gobuffalo/mapi v1.0.2/go.mod h1:4VAGh89y6rVOvm5A8fKFxYG+wIW6LO1FMTG9hnKStFc= +github.com/gobuffalo/packd v0.0.0-20190315124812-a385830c7fc0/go.mod h1:M2Juc+hhDXf/PnmBANFCqx4DM3wRbgDvnVWeG2RIxq4= +github.com/gobuffalo/packd v0.1.0 h1:4sGKOD8yaYJ+dek1FDkwcxCHA40M4kfKgFHx8N2kwbU= +github.com/gobuffalo/packd v0.1.0/go.mod h1:M2Juc+hhDXf/PnmBANFCqx4DM3wRbgDvnVWeG2RIxq4= github.com/gobuffalo/packr v1.13.2 h1:fQmeSiOMhl+4U+da7VmX2AjdcCaSOi5IvnqsSXdKYmQ= github.com/gobuffalo/packr v1.13.2/go.mod h1:qdqw8AgJyKw60qj56fnEBiS9fIqqCaP/vWJQvR4Jcss= +github.com/gobuffalo/packr v1.25.0 h1:NtPK45yOKFdTKHTvRGKL+UIKAKmJVWIVJOZBDI/qEdY= +github.com/gobuffalo/packr v1.25.0/go.mod h1:NqsGg8CSB2ZD+6RBIRs18G7aZqdYDlYNNvsSqP6T4/U= +github.com/gobuffalo/packr/v2 v2.0.9/go.mod h1:emmyGweYTm6Kdper+iywB6YK5YzuKchGtJQZ0Odn4pQ= +github.com/gobuffalo/packr/v2 v2.1.0/go.mod h1:n90ZuXIc2KN2vFAOQascnPItp9A2g9QYSvYvS3AjQEM= +github.com/gobuffalo/packr/v2 v2.2.0 h1:Ir9W9XIm9j7bhhkKE9cokvtTl1vBm62A/fene/ZCj6A= +github.com/gobuffalo/packr/v2 v2.2.0/go.mod h1:CaAwI0GPIAv+5wKLtv8Afwl+Cm78K/I/VCm/3ptBN+0= +github.com/gobuffalo/syncx v0.0.0-20190224160051-33c29581e754 h1:tpom+2CJmpzAWj5/VEHync2rJGi+epHNIeRSWjzGA+4= +github.com/gobuffalo/syncx v0.0.0-20190224160051-33c29581e754/go.mod h1:HhnNqWY95UYwwW3uSASeV7vtgYkT2t16hJgV3AEPUpw= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= github.com/golang/protobuf v1.1.0 h1:0iH4Ffd/meGoXqF2lSAhZHt8X+cPgkfn/cb6Cce5Vpc= @@ -31,7 +67,13 @@ github.com/golang/protobuf v1.3.1 h1:YF8+flBXS5eO826T4nzqPrxfhQThhXl0YzfuUPu4SBg github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/hashicorp/hcl v0.0.0-20180404174102-ef8a98b0bbce h1:xdsDDbiBDQTKASoGEZ+pEmF1OnWuu8AQ9I8iNbHNeno= github.com/hashicorp/hcl v0.0.0-20180404174102-ef8a98b0bbce/go.mod h1:oZtUIOe8dh44I2q6ScRibXws4Ajl+d+nod3AaR9vL5w= +github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc= +github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/karrick/godirwalk v1.8.0/go.mod h1:H5KPZjojv4lE+QYImBI8xVtrBRgYrIVsaRPx4tDPEn4= +github.com/karrick/godirwalk v1.10.0 h1:fb2G3xs9hsG0CmH6fnx6sxTsvNeDQtcsIegljcXRQGU= +github.com/karrick/godirwalk v1.10.0/go.mod h1:RoGL9dQei4vP9ilrpETWE8CLOZ1kiN0LhBygSwrAsHA= github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= @@ -49,6 +91,10 @@ github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/magiconair/properties v1.8.0 h1:LLgXmsheXeRoUOBOjtwPQCWIYqM/LU1ayDtDePerRcY= github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/markbates/grift v1.0.1/go.mod h1:aC7s7OfCOzc2WCafmTm7wI3cfGFA/8opYhdTGlIAmmo= +github.com/markbates/oncer v0.0.0-20181203154359-bf2de49a0be2 h1:JgVTCPf0uBVcUSWpyXmGpgOc62nK5HWUBKAGc3Qqa5k= +github.com/markbates/oncer v0.0.0-20181203154359-bf2de49a0be2/go.mod h1:Ld9puTsIW75CHf65OeIOkyKbteujpZVXDpWK6YGZbxE= +github.com/markbates/safe v1.0.1 h1:yjZkbvRM6IzKj9tlu/zMJLS0n/V351OZWRnF3QfaUxI= +github.com/markbates/safe v1.0.1/go.mod h1:nAqgmRi7cY2nqMc92/bSEeQA+R4OheNU2T1kNSCBdG0= github.com/mattn/go-isatty v0.0.0-20170307163044-57fdcb988a5c h1:AHfQR/s6GNi92TOh+kfGworqDvTxj2rMsS+Hca87nck= github.com/mattn/go-isatty v0.0.0-20170307163044-57fdcb988a5c/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= @@ -87,20 +133,28 @@ github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084 h1:sofwID9zm4tzr github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= github.com/prometheus/promu v0.3.0 h1:ecIZ1FIjQ+PAneA6g0KpUa7FDimozQtDjzI2rW0Pmh0= github.com/prometheus/promu v0.3.0/go.mod h1:+NXvSS3J95z3ZmFZP0DXUt+g/I6zyK1CQoBJKkjzX4k= +github.com/rogpeppe/go-internal v1.1.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rogpeppe/go-internal v1.2.2/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rogpeppe/go-internal v1.3.0 h1:RR9dF3JtopPvtkroDZuVD7qquD0bnHlKSqaQhgwt8yk= +github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/sirupsen/logrus v1.0.6 h1:hcP1GmhGigz/O7h1WVUM5KklBp1JoNS9FggWKdj/j3s= github.com/sirupsen/logrus v1.0.6/go.mod h1:pMByvHTf9Beacp5x1UXfOR9xyW/9antXMhjMPG0dEzc= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sirupsen/logrus v1.4.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.1 h1:GL2rEmy6nsikmW0r8opw9JIRScdMF5hA8cOYLH7In1k= github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= github.com/spf13/afero v1.1.1 h1:Lt3ihYMlE+lreX1GS4Qw4ZsNpYQLxIXKBTEOXm3nt6I= github.com/spf13/afero v1.1.1/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= github.com/spf13/cast v1.2.0 h1:HHl1DSRbEQN2i8tJmtS6ViPyHx35+p51amrdsiTCrkg= github.com/spf13/cast v1.2.0/go.mod h1:r2rcYCSwa1IExKTDiTfzaxqT2FNHs8hODu4LnUfgKEg= +github.com/spf13/cobra v0.0.3 h1:ZlrZ4XsMRm04Fr5pSFxBgfND2EBVa1nLpiy1stUsX/8= github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= github.com/spf13/jwalterweatherman v0.0.0-20180814060501-14d3d4c51834 h1:kJI9pPzfsULT/72wy7mxkRQZPtKWgFdCA2RTGZ4v8/E= github.com/spf13/jwalterweatherman v0.0.0-20180814060501-14d3d4c51834/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= github.com/spf13/pflag v1.0.2 h1:Fy0orTDgHdbnzHcsOgfCN4LtHf0ec3wwtiwJqwvf3Gc= github.com/spf13/pflag v1.0.2/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg= +github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= github.com/spf13/viper v1.1.0 h1:V7OZpY8i3C1x/pDmU0zNNlfVoDz112fSYvtWMjjS3f4= github.com/spf13/viper v1.1.0/go.mod h1:A8kyI5cUJhb8N+3pkfONlcEcZbueH6nhAm0Fq7SrnBM= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -116,6 +170,8 @@ golang.org/x/crypto v0.0.0-20180808211826-de0752318171 h1:vYogbvSFj2YXcjQxFHu/rA golang.org/x/crypto v0.0.0-20180808211826-de0752318171/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190422162423-af44ce270edf/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE= +golang.org/x/crypto v0.0.0-20190506204251-e1dfcc566284 h1:rlLehGeYg6jfoyz/eDqDU1iRXLKfR42nnNh57ytKEWo= golang.org/x/crypto v0.0.0-20190506204251-e1dfcc566284/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/net v0.0.0-20180811021610-c39426892332 h1:efGso+ep0DjyCBJPjvoz0HI6UldX4Md2F1rZFe1ir0E= golang.org/x/net v0.0.0-20180811021610-c39426892332/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -128,15 +184,22 @@ golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190412183630-56d357773e84/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58 h1:8gQV6CLnAEikrhgkHFbMAEhagSSnXWGV915qUMm9mrU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180810173357-98c5dad5d1a0 h1:8H8QZJ30plJyIVj60H3lr8TZGIq2Fh3Cyrs/ZNg1foU= golang.org/x/sys v0.0.0-20180810173357-98c5dad5d1a0/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190419153524-e8e3143a4f4a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b h1:ag/x1USPSsqHud38I9BAC88qdNLDHHtQ4mlgQIZPPNA= golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190509141414-a5b02f93d862 h1:rM0ROo5vb9AdYJi1110yjWGMej9ITfKddS89P3Fkhug= +golang.org/x/sys v0.0.0-20190509141414-a5b02f93d862/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= @@ -144,11 +207,18 @@ golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190329151228-23e29df326fe/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190404132500-923d25813098/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190416151739-9c9e1878f421/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190420181800-aa740d480789/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190509014725-d996b19ee77c h1:FsgttePhaNW32agh7vOjhKj0IuEmI/TmGumOc4z9yEs= +golang.org/x/tools v0.0.0-20190509014725-d996b19ee77c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= gopkg.in/alecthomas/kingpin.v2 v2.2.6 h1:jMFz6MfLP0/4fUyZle81rXUoxOBFi19VUFKVDOQfozc= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/go-playground/validator.v8 v8.18.1 h1:F8SLY5Vqesjs1nI1EL4qmF1PQZ1sitsmq0rPYXLyfGU= gopkg.in/go-playground/validator.v8 v8.18.1/go.mod h1:RX2a/7Ha8BgOhfk7j780h4/u/RRjR0eouCJSH80/M2Y= gopkg.in/yaml.v2 v2.0.0-20160928153709-a5b47d31c556 h1:hKXbLW5oaJoQgs8KrzTLdF4PoHi+0oQPgea9TNtvE3E= From 741108cd2100cf9f4d3a13f771d1ebe5132baae6 Mon Sep 17 00:00:00 2001 From: Chaz Larson Date: Thu, 9 May 2019 15:06:38 -0500 Subject: [PATCH 090/114] Use standard crossbuild for docker build instead of spinning up a docker build container. --- Dockerfile | 17 +---------------- Makefile | 2 +- 2 files changed, 2 insertions(+), 17 deletions(-) diff --git a/Dockerfile b/Dockerfile index 6f62dc9..7284ee9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,20 +1,5 @@ -FROM golang:alpine as builder - -# Install git because gin/yaml needs it -# Install make for building -RUN apk update && apk upgrade && apk add git make - -# Copy the code from the host and compile it -WORKDIR $GOPATH/src/github.com/tellytv/telly -COPY . ./ - -# Build the executable using promu since that builds in the version info -# copy the resulting executable to the root under the name "app" -RUN make promu && make build && mv ./telly /app - FROM scratch -# Original: copy from the builder image above: -COPY --from=builder /app ./ +COPY .build/linux-amd64/telly ./app EXPOSE 6077 ENTRYPOINT ["./app"] diff --git a/Makefile b/Makefile index e0e0f39..16bb51d 100644 --- a/Makefile +++ b/Makefile @@ -42,7 +42,7 @@ tarball: promu @echo ">> building release tarball" @$(PROMU) tarball $(BIN_DIR) -docker: +docker: cross @echo ">> building docker image" @docker build -t "$(DOCKER_IMAGE_NAME):$(DOCKER_IMAGE_TAG)" . From 781979dce7269e61f548ee0994c81eb7be6ef8af Mon Sep 17 00:00:00 2001 From: 5Ub-Z3r0 <1673590+5Ub-Z3r0@users.noreply.github.com> Date: Sat, 25 May 2019 16:32:16 +0200 Subject: [PATCH 091/114] Support multicast streams through udpxy in the custom provider (#238) * m3uplus parser: support UDP streams. * Allow customers using the custom provider to use udpxy as a multicast proxy. --- internal/m3uplus/main.go | 2 +- internal/providers/custom.go | 17 +++++++++++++++++ internal/providers/main.go | 2 ++ 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/internal/m3uplus/main.go b/internal/m3uplus/main.go index 712e539..62bf77a 100644 --- a/internal/m3uplus/main.go +++ b/internal/m3uplus/main.go @@ -96,7 +96,7 @@ func decodeLine(playlist *Playlist, line string, lineNumber int) error { playlist.Tracks = append(playlist.Tracks, track) - case strings.HasPrefix(line, "http"): + case strings.HasPrefix(line, "http") || strings.HasPrefix(line, "udp"): playlist.Tracks[len(playlist.Tracks)-1].URI = line } diff --git a/internal/providers/custom.go b/internal/providers/custom.go index 721ddc9..6e0b824 100644 --- a/internal/providers/custom.go +++ b/internal/providers/custom.go @@ -1,9 +1,13 @@ package providers import ( + "fmt" + "net" + "net/url" "strconv" "strings" + log "github.com/sirupsen/logrus" m3u "github.com/tellytv/telly/internal/m3uplus" "github.com/tellytv/telly/internal/xmltv" ) @@ -63,6 +67,19 @@ func (i *customProvider) ParseTrack(track m3u.Track, channelMap map[string]xmltv OnDemand: false, } + // If Udpxy is set in the provider configuration and StreamURL is a multicast stream, + // rewrite the URL to point to the Udpxy instance. + if i.BaseConfig.Udpxy != "" { + trackURI, err := url.Parse(pChannel.StreamURL) + if err != nil { + return nil, err + } + if IP := net.ParseIP(trackURI.Hostname()); IP != nil && IP.IsMulticast() { + pChannel.StreamURL = fmt.Sprintf("http://%s/udp/%s/", i.BaseConfig.Udpxy, trackURI.Host) + log.Debugf("Multicast stream detected and udpxy is configured, track URL rewritten from %s to %s", track.URI, pChannel.StreamURL) + } + } + epgVal := track.Tags["tvg-id"] if i.BaseConfig.EPGMatchKey != "" { epgVal = track.Tags[i.BaseConfig.EPGMatchKey] diff --git a/internal/providers/main.go b/internal/providers/main.go index 3f7475f..6f206b0 100644 --- a/internal/providers/main.go +++ b/internal/providers/main.go @@ -23,6 +23,8 @@ type Configuration struct { M3U string `json:"-"` EPG string `json:"-"` + Udpxy string `json:"udpxy"` + VideoOnDemand bool `json:"-"` Filter string From 9eb5e99c7f01614e28be49d4fc1e18a043d91821 Mon Sep 17 00:00:00 2001 From: Aman Gupta Date: Wed, 9 Oct 2019 18:03:16 -0700 Subject: [PATCH 092/114] ensure that ffmpeg processes are reaped to prevent zombies (#262) --- routes.go | 1 + 1 file changed, 1 insertion(+) diff --git a/routes.go b/routes.go index 963f256..c50c9f2 100644 --- a/routes.go +++ b/routes.go @@ -205,6 +205,7 @@ func stream(lineup *lineup) gin.HandlerFunc { log.WithError(startErr).Errorln("Error starting ffmpeg") return } + defer run.Wait() go func() { scanner := bufio.NewScanner(stderr) From 61e41a77f7fa227a6525000e13028b1c40d0445c Mon Sep 17 00:00:00 2001 From: iMx <17220013+iMiMx@users.noreply.github.com> Date: Sun, 12 Jan 2020 02:39:30 +0000 Subject: [PATCH 093/114] Remove -re from ffmpeg command (#267) Remove '-re' from ffmpeg command, was causing buffering/packet loss. From the ffmpeg docs: -re (input) Read input at native frame rate. Mainly used to simulate a grab device, or live input stream (e.g. when reading from a file). Should not be used with actual grab devices or live input streams (where it can cause packet loss). --- routes.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/routes.go b/routes.go index c50c9f2..9f0f35d 100644 --- a/routes.go +++ b/routes.go @@ -189,7 +189,7 @@ func stream(lineup *lineup) gin.HandlerFunc { log.Infoln("Remuxing stream with ffmpeg") - run := exec.Command("ffmpeg", "-re", "-i", channel.providerChannel.Track.URI, "-codec", "copy", "-f", "mpegts", "pipe:1") + run := exec.Command("ffmpeg", "-i", channel.providerChannel.Track.URI, "-codec", "copy", "-f", "mpegts", "pipe:1") ffmpegout, err := run.StdoutPipe() if err != nil { log.WithError(err).Errorln("StdoutPipe Error") From e1d1d23780b6782977048331b46791dad33f2f52 Mon Sep 17 00:00:00 2001 From: Charles Larson Date: Sun, 12 Jan 2020 07:50:09 -0600 Subject: [PATCH 094/114] Remove references to non-Custom providers. --- internal/providers/main.go | 6 ------ 1 file changed, 6 deletions(-) diff --git a/internal/providers/main.go b/internal/providers/main.go index 6f206b0..28039fb 100644 --- a/internal/providers/main.go +++ b/internal/providers/main.go @@ -50,12 +50,6 @@ type Configuration struct { func (i *Configuration) GetProvider() (Provider, error) { switch strings.ToLower(i.Provider) { - case "iptv-epg", "iptvepg": - return newIPTVEPG(i) - case "iris", "iristv": - return newIris(i) - case "area51": - return newArea51(i) default: return newCustomProvider(i) } From 3a80982b1e7121bc04b88690bc95bb3ca50d8908 Mon Sep 17 00:00:00 2001 From: Charles Larson Date: Sun, 12 Jan 2020 07:51:48 -0600 Subject: [PATCH 095/114] Add a few more filetypes to ignore. --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 9143f10..4c0126a 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,6 @@ vendor/ /.tarballs *.tar.gz telly.config.* +.idea/* +telly.db +a_main-packr.go \ No newline at end of file From 2565f2d5a569f54edb869bf8dfa22e364e603f31 Mon Sep 17 00:00:00 2001 From: Charles Larson Date: Sun, 12 Jan 2020 07:56:44 -0600 Subject: [PATCH 096/114] Issues #185, #265 --- routes.go | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/routes.go b/routes.go index 9f0f35d..13ebc54 100644 --- a/routes.go +++ b/routes.go @@ -180,16 +180,24 @@ func stream(lineup *lineup) gin.HandlerFunc { } if channel, ok := lineup.channels[channelID]; ok { + channelURI := channel.providerChannel.Track.URI + log.Infof("Serving channel number %d", channelID) - if !viper.IsSet("iptv.ffmpeg") { - c.Redirect(http.StatusMovedPermanently, channel.providerChannel.Track.URI) + useFFMpeg := viper.IsSet("iptv.ffmpeg") + if useFFMpeg { + useFFMpeg = viper.GetBool("iptv.ffmpeg") + } + + if !useFFMpeg { + log.Debugf("Redirecting caller to %s", channelURI) + c.Redirect(http.StatusMovedPermanently, channelURI) return } log.Infoln("Remuxing stream with ffmpeg") - run := exec.Command("ffmpeg", "-i", channel.providerChannel.Track.URI, "-codec", "copy", "-f", "mpegts", "pipe:1") + run := exec.Command("ffmpeg", "-i", channelURI, "-codec", "copy", "-f", "mpegts", "pipe:1") ffmpegout, err := run.StdoutPipe() if err != nil { log.WithError(err).Errorln("StdoutPipe Error") From e0f8ac5f28e3624aed62adf1f344939311a48e60 Mon Sep 17 00:00:00 2001 From: Charles Larson Date: Sun, 12 Jan 2020 08:48:47 -0600 Subject: [PATCH 097/114] Trimmed build platforms --- .promu.yml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/.promu.yml b/.promu.yml index dbc7186..ee16bbb 100644 --- a/.promu.yml +++ b/.promu.yml @@ -14,13 +14,12 @@ tarball: - NOTICE crossbuild: platforms: - - linux/amd64 - linux/386 + - linux/amd64 + - linux/arm + - linux/arm64 - darwin/amd64 - - darwin/386 - windows/amd64 - windows/386 - freebsd/amd64 - freebsd/386 - - linux/arm - - linux/arm64 From a9fc521ff21c372b10c2ce3123072134a4086a9f Mon Sep 17 00:00:00 2001 From: Charles Larson Date: Sun, 12 Jan 2020 08:50:09 -0600 Subject: [PATCH 098/114] tarballs require crossbuild --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 16bb51d..885a4b7 100644 --- a/Makefile +++ b/Makefile @@ -30,7 +30,7 @@ cross: promu @echo ">> crossbuilding binaries" @$(PROMU) crossbuild -tarballs: promu +tarballs: promu cross @echo ">> creating release tarballs" @$(PROMU) crossbuild tarballs From b3d502aeebff30393a5530408ed0037ed3dab516 Mon Sep 17 00:00:00 2001 From: Charles Larson Date: Sun, 12 Jan 2020 08:50:39 -0600 Subject: [PATCH 099/114] bump version --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 6d75618..11910f6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.0.6 +1.1.0.7 From 5820525faa5e04a545b5708cdd9d7d59b59001d4 Mon Sep 17 00:00:00 2001 From: Charles Larson Date: Sat, 4 Jul 2020 00:17:52 -0500 Subject: [PATCH 100/114] basic fix for crash on negative 'stop' times in XML --- internal/xmltv/xmltv.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/internal/xmltv/xmltv.go b/internal/xmltv/xmltv.go index 7ea9d6f..731d6d4 100644 --- a/internal/xmltv/xmltv.go +++ b/internal/xmltv/xmltv.go @@ -26,6 +26,13 @@ func (t *Time) MarshalXMLAttr(name xml.Name) (xml.Attr, error) { // UnmarshalXMLAttr is used to unmarshal a time in the XMLTV format to a time.Time. func (t *Time) UnmarshalXMLAttr(attr xml.Attr) error { + // This is a barebones handling of broken XMLTV entries like this one: + // + // What's that negative stop time about? Ignore it + if strings.HasPrefix(attr.Value, "-") { + return nil + } + t1, err := time.Parse("20060102150405 -0700", attr.Value) if err != nil { return err From c3df9263925fc9ddf2cb6482d180c2120abcc772 Mon Sep 17 00:00:00 2001 From: Charles Larson Date: Sat, 4 Jul 2020 00:18:54 -0500 Subject: [PATCH 101/114] add suggestion to check the filter if 0 channels are loaded --- lineup.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lineup.go b/lineup.go index 5e02820..4eaa15c 100644 --- a/lineup.go +++ b/lineup.go @@ -205,6 +205,10 @@ func (l *lineup) processProvider(provider providers.Provider) (int, error) { log.Infof("Loaded %d channels into the lineup from %s", addedChannels, provider.Name()) + if addedChannels == 0 { + log.Infof("Check your filter; %d channels were blocked by it", len(failedChannels)) + } + return addedChannels, nil } From fcbed9263bf1b80a4f14a0b4342836abca737f6c Mon Sep 17 00:00:00 2001 From: Charles Larson Date: Sat, 4 Jul 2020 00:19:47 -0500 Subject: [PATCH 102/114] add lineup URL to startup logging --- routes.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/routes.go b/routes.go index 13ebc54..02ffbdb 100644 --- a/routes.go +++ b/routes.go @@ -100,8 +100,9 @@ func serve(lineup *lineup) { router.StaticFS("/manage", box) log.Infof("telly is live and on the air!") - log.Infof("Broadcasting from http://%s/", viper.GetString("web.listen-address")) - log.Infof("EPG URL: http://%s/epg.xml", viper.GetString("web.listen-address")) + log.Infof("Broadcasting from http://%s/", viper.GetString("web.base-address")) + log.Infof("EPG URL: http://%s/epg.xml", viper.GetString("web.base-address")) + log.Infof("Lineup JSON: http://%s/lineup.json", viper.GetString("web.base-address")) if err := router.Run(viper.GetString("web.listen-address")); err != nil { log.WithError(err).Panicln("Error starting up web server") From 96424a4719f33de9c1b66d8d2186e0452e45a234 Mon Sep 17 00:00:00 2001 From: Charles Larson Date: Sat, 4 Jul 2020 00:20:05 -0500 Subject: [PATCH 103/114] bump version --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 11910f6..8ad4501 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.0.7 +1.1.0.8 From f43e486ae42864ce7c3c061abcfa78f76a56ea42 Mon Sep 17 00:00:00 2001 From: Chaz Larson Date: Wed, 11 Nov 2020 16:59:42 -0600 Subject: [PATCH 104/114] Fix merge mikstake --- routes.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/routes.go b/routes.go index f95b383..aaef5dd 100644 --- a/routes.go +++ b/routes.go @@ -283,7 +283,7 @@ func ginrus() gin.HandlerFunc { } } -func setupSSDP(baseAddress, deviceName, deviceUUID string) error { +func setupSSDP(baseAddress, deviceName, deviceUUID string) (*ssdp.Advertiser, error) { log.Debugf("Advertising telly as %s (%s)", deviceName, deviceUUID) adv, err := ssdp.Advertise( @@ -294,7 +294,7 @@ func setupSSDP(baseAddress, deviceName, deviceUUID string) error { 1800) if err != nil { - return err + return nil, err } go func(advertiser *ssdp.Advertiser) { @@ -308,7 +308,7 @@ func setupSSDP(baseAddress, deviceName, deviceUUID string) error { } }(adv) - return nil + return adv, nil } func split(data []byte, atEOF bool) (advance int, token []byte, spliterror error) { From 6208fd136453a8584ec60c034bc4a65b6823a185 Mon Sep 17 00:00:00 2001 From: Chris Blum Date: Tue, 2 Nov 2021 16:59:49 +0100 Subject: [PATCH 105/114] More robust URI parsing in M3U files (#286) * Add ffmpeg command to debug log * More robust URI parsing in M3U files * Exit if processProviderChannel returns error That function nowadays doesn't ever return an error, but that might change in the future * Warn user if URI is not http or udp and ffmpeg is disabled Also moved URI of Track to net/url for better URI handling --- internal/m3uplus/main.go | 14 +++++++++++--- internal/providers/area51.go | 2 +- internal/providers/custom.go | 2 +- internal/providers/iptv-epg.go | 2 +- internal/providers/iris.go | 2 +- lineup.go | 17 ++++++++++++++++- routes.go | 12 ++++-------- 7 files changed, 35 insertions(+), 16 deletions(-) diff --git a/internal/m3uplus/main.go b/internal/m3uplus/main.go index 62bf77a..b6abc12 100644 --- a/internal/m3uplus/main.go +++ b/internal/m3uplus/main.go @@ -5,6 +5,7 @@ import ( "bytes" "fmt" "io" + "net/url" "regexp" "strconv" "strings" @@ -21,7 +22,7 @@ type Playlist struct { type Track struct { Name string Length float64 - URI string + URI *url.URL Tags map[string]string Raw string LineNumber int @@ -96,13 +97,20 @@ func decodeLine(playlist *Playlist, line string, lineNumber int) error { playlist.Tracks = append(playlist.Tracks, track) - case strings.HasPrefix(line, "http") || strings.HasPrefix(line, "udp"): - playlist.Tracks[len(playlist.Tracks)-1].URI = line + case IsUrl(line): + uri, _ := url.Parse(line) + playlist.Tracks[len(playlist.Tracks)-1].URI = uri } return nil } +// From https://stackoverflow.com/questions/25747580/ensure-a-uri-is-valid/25747925#25747925 +func IsUrl(str string) bool { + u, err := url.Parse(str) + return err == nil && u.Scheme != "" && u.Host != "" +} + var infoRegex = regexp.MustCompile(`([^\s="]+)=(?:"(.*?)"|(\d+))(?:,([.*^,]))?|#EXTINF:(-?\d*\s*)|,(.*)`) func decodeInfoLine(line string) (float64, string, map[string]string) { diff --git a/internal/providers/area51.go b/internal/providers/area51.go index e2c2f87..5d8a5a4 100644 --- a/internal/providers/area51.go +++ b/internal/providers/area51.go @@ -47,7 +47,7 @@ func (i *area51) ParseTrack(track m3u.Track, channelMap map[string]xmltv.Channel Name: nameVal, Logo: logoVal, Number: 0, - StreamURL: track.URI, + StreamURL: track.URI.String(), StreamID: 0, HD: strings.Contains(strings.ToLower(track.Name), "hd"), StreamFormat: "Unknown", diff --git a/internal/providers/custom.go b/internal/providers/custom.go index 6e0b824..ebf0d31 100644 --- a/internal/providers/custom.go +++ b/internal/providers/custom.go @@ -59,7 +59,7 @@ func (i *customProvider) ParseTrack(track m3u.Track, channelMap map[string]xmltv Name: nameVal, Logo: logoVal, Number: chanNum, - StreamURL: track.URI, + StreamURL: track.URI.String(), StreamID: chanNum, HD: strings.Contains(strings.ToLower(track.Name), "hd"), StreamFormat: "Unknown", diff --git a/internal/providers/iptv-epg.go b/internal/providers/iptv-epg.go index 258239b..f34c496 100644 --- a/internal/providers/iptv-epg.go +++ b/internal/providers/iptv-epg.go @@ -58,7 +58,7 @@ func (i *iptvepg) ParseTrack(track m3u.Track, channelMap map[string]xmltv.Channe Name: nameVal, Logo: logoVal, Number: channelNumber, - StreamURL: track.URI, + StreamURL: track.URI.String(), StreamID: channelNumber, HD: strings.Contains(strings.ToLower(track.Name), "hd"), StreamFormat: "Unknown", diff --git a/internal/providers/iris.go b/internal/providers/iris.go index c05814a..7271336 100644 --- a/internal/providers/iris.go +++ b/internal/providers/iris.go @@ -47,7 +47,7 @@ func (i *iris) ParseTrack(track m3u.Track, channelMap map[string]xmltv.Channel) Name: nameVal, Logo: logoVal, Number: 0, - StreamURL: track.URI, + StreamURL: track.URI.String(), StreamID: 0, HD: strings.Contains(strings.ToLower(track.Name), "hd"), StreamFormat: "Unknown", diff --git a/lineup.go b/lineup.go index 4eaa15c..53b6715 100644 --- a/lineup.go +++ b/lineup.go @@ -14,7 +14,7 @@ import ( "time" "github.com/spf13/viper" - "github.com/tellytv/go.schedulesdirect" + schedulesdirect "github.com/tellytv/go.schedulesdirect" m3u "github.com/tellytv/telly/internal/m3uplus" "github.com/tellytv/telly/internal/providers" "github.com/tellytv/telly/internal/xmltv" @@ -70,6 +70,8 @@ type lineup struct { channels map[int]hdHomeRunLineupItem sd *schedulesdirect.Client + + FfmpegEnabled bool } // newLineup returns a new lineup for the given config struct. @@ -95,10 +97,16 @@ func newLineup() *lineup { }) } + useFFMpeg := viper.IsSet("iptv.ffmpeg") + if useFFMpeg { + useFFMpeg = viper.GetBool("iptv.ffmpeg") + } + lineup := &lineup{ assignedChannelNumber: viper.GetInt("iptv.starting-channel"), xmlTVChannelNumbers: viper.GetBool("iptv.xmltv-channels"), channels: make(map[int]hdHomeRunLineupItem), + FfmpegEnabled: useFFMpeg, } if viper.IsSet("schedulesdirect.username") && viper.IsSet("schedulesdirect.password") { @@ -191,6 +199,7 @@ func (l *lineup) processProvider(provider providers.Provider) (int, error) { channel, processErr := l.processProviderChannel(channel, programmeMap) if processErr != nil { log.WithError(processErr).Errorln("error processing track") + continue } else if channel == nil { log.Infof("Channel %s was returned empty from the provider (%s)", track.Name, provider.Name()) continue @@ -227,6 +236,12 @@ func (l *lineup) prepareProvider(provider providers.Provider) (*m3u.Playlist, ma return nil, nil, nil, err } + for _, playlistTrack := range rawPlaylist.Tracks { + if (playlistTrack.URI.Scheme == "http" || playlistTrack.URI.Scheme == "udp") && !l.FfmpegEnabled { + log.Errorf("The playlist you tried to add has at least one entry using a protocol other than http or udp and you have ffmpeg disabled in your config. This will most likely not work. Offending URI is %s", playlistTrack.URI) + } + } + if closeM3UErr := reader.Close(); closeM3UErr != nil { log.WithError(closeM3UErr).Panicln("error when closing m3u reader") } diff --git a/routes.go b/routes.go index aaef5dd..581868a 100644 --- a/routes.go +++ b/routes.go @@ -185,20 +185,16 @@ func stream(lineup *lineup) gin.HandlerFunc { log.Infof("Serving channel number %d", channelID) - useFFMpeg := viper.IsSet("iptv.ffmpeg") - if useFFMpeg { - useFFMpeg = viper.GetBool("iptv.ffmpeg") - } - - if !useFFMpeg { + if !lineup.FfmpegEnabled { log.Debugf("Redirecting caller to %s", channelURI) - c.Redirect(http.StatusMovedPermanently, channelURI) + c.Redirect(http.StatusMovedPermanently, channelURI.String()) return } log.Infoln("Remuxing stream with ffmpeg") - run := exec.Command("ffmpeg", "-i", channelURI, "-codec", "copy", "-f", "mpegts", "pipe:1") + run := exec.Command("ffmpeg", "-i", channelURI.String(), "-codec", "copy", "-f", "mpegts", "pipe:1") + log.Debugf("Executing ffmpeg as \"%s\"", strings.Join(run.Args, " ")) ffmpegout, err := run.StdoutPipe() if err != nil { log.WithError(err).Errorln("StdoutPipe Error") From 55cf6b27fc77c90ee01621e2dfc59051aa3eca16 Mon Sep 17 00:00:00 2001 From: Chaz Larson Date: Thu, 3 Feb 2022 16:26:19 -0600 Subject: [PATCH 106/114] Update README.md --- README.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 2a58404..fb1a9b5 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,11 @@ IPTV proxy for Plex Live written in Golang Please refer to the [Wiki](https://github.com/tellytv/telly/wiki) for the most current documentation. -## This readme refers to version ![#0eaf29](https://placehold.it/15/0eaf29/000000?text=+) 1.1.x ![#0eaf29](https://placehold.it/15/0eaf29/000000?text=+). It does not apply to versions other than that. +## This readme refers to version 1.1.x . It does not apply to versions other than that. The [Wiki](https://github.com/tellytv/telly/wiki) includes walkthroughs for most platforms that go into more detail than listed below: -## ![#f03c15](https://placehold.it/15/f03c15/000000?text=+) THIS IS A DEVELOPMENT BRANCH ![#f03c15](https://placehold.it/15/f03c15/000000?text=+) +## THIS IS A DEVELOPMENT BRANCH It is under active development and things may change quickly and dramatically. Please join the discord server if you use this branch and be prepared for some tinkering and breakage. @@ -87,7 +87,6 @@ Here's an example configuration file. **You will need to create this file.** It Sort = "group-title" # Sort will alphabetically sort your channels by the M3U key provided # END TELLY CONFIG ############################################################################### ``` -![#f03c15](https://placehold.it/15/f03c15/000000?text=+) You only need one source; the ones you are not using should be commented out or deleted.![#f03c15](https://placehold.it/15/f03c15/000000?text=+) The name and filter-related keys can be used with any of the sources. # FFMpeg From a6a50400c893db75fcaa471506aa07891be11721 Mon Sep 17 00:00:00 2001 From: Chaz Larson Date: Thu, 30 Nov 2023 14:16:35 -0600 Subject: [PATCH 107/114] Create go.yml --- .github/workflows/go.yml | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 .github/workflows/go.yml diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml new file mode 100644 index 0000000..89c6a51 --- /dev/null +++ b/.github/workflows/go.yml @@ -0,0 +1,28 @@ +# This workflow will build a golang project +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go + +name: Go + +on: + push: + branches: [ "dev" ] + pull_request: + branches: [ "dev" ] + +jobs: + + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: '1.20' + + - name: Build + run: go build -v ./... + + - name: Test + run: go test -v ./... From 4eb58f85dfda6ddce12bb811c5dd1b12f73664b5 Mon Sep 17 00:00:00 2001 From: Chaz Larson Date: Thu, 30 Nov 2023 14:19:18 -0600 Subject: [PATCH 108/114] Change ffmpeg command --- routes.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/routes.go b/routes.go index 581868a..225d87a 100644 --- a/routes.go +++ b/routes.go @@ -192,8 +192,7 @@ func stream(lineup *lineup) gin.HandlerFunc { } log.Infoln("Remuxing stream with ffmpeg") - - run := exec.Command("ffmpeg", "-i", channelURI.String(), "-codec", "copy", "-f", "mpegts", "pipe:1") + run := exec.Command("ffmpeg", "-i", "pipe:0", "-c:v", "copy", "-f", "mpegts", "pipe:1") log.Debugf("Executing ffmpeg as \"%s\"", strings.Join(run.Args, " ")) ffmpegout, err := run.StdoutPipe() if err != nil { From afc5bbc4690e0fadbbc454dfae10e54f2d043db2 Mon Sep 17 00:00:00 2001 From: Chaz Larson Date: Thu, 30 Nov 2023 14:24:26 -0600 Subject: [PATCH 109/114] Delete .github/workflows/go.yml --- .github/workflows/go.yml | 28 ---------------------------- 1 file changed, 28 deletions(-) delete mode 100644 .github/workflows/go.yml diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml deleted file mode 100644 index 89c6a51..0000000 --- a/.github/workflows/go.yml +++ /dev/null @@ -1,28 +0,0 @@ -# This workflow will build a golang project -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go - -name: Go - -on: - push: - branches: [ "dev" ] - pull_request: - branches: [ "dev" ] - -jobs: - - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - - name: Set up Go - uses: actions/setup-go@v4 - with: - go-version: '1.20' - - - name: Build - run: go build -v ./... - - - name: Test - run: go test -v ./... From 8f3e39403d1698f2e1bf99d073e392f5ffa6977b Mon Sep 17 00:00:00 2001 From: Chaz Larson Date: Thu, 30 Nov 2023 14:39:40 -0600 Subject: [PATCH 110/114] do nothing --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 684859d..7234763 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -8,5 +8,5 @@ jobs: - checkout # - run: go get -u github.com/alecthomas/gometalinter # - run: gometalinter --install - - run: go test -v ./... + # - run: go test -v ./... # - run: gometalinter --config=.gometalinter.json ./... From 15d6b72b2772436d7cb2ff90ff51929a07902fca Mon Sep 17 00:00:00 2001 From: Chaz Larson Date: Thu, 30 Nov 2023 14:42:39 -0600 Subject: [PATCH 111/114] disable circleci --- .circleci/config.yml | 12 ------------ 1 file changed, 12 deletions(-) delete mode 100644 .circleci/config.yml diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index 7234763..0000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,12 +0,0 @@ -version: 2 -jobs: - build: - docker: - - image: circleci/golang:1 - - steps: - - checkout - # - run: go get -u github.com/alecthomas/gometalinter - # - run: gometalinter --install - # - run: go test -v ./... - # - run: gometalinter --config=.gometalinter.json ./... From 694d4fd4f37618af6b8165d62932d261ad9df11b Mon Sep 17 00:00:00 2001 From: Chaz Larson Date: Thu, 30 Nov 2023 14:43:47 -0600 Subject: [PATCH 112/114] Create go.yml --- .github/workflows/go.yml | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 .github/workflows/go.yml diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml new file mode 100644 index 0000000..396d617 --- /dev/null +++ b/.github/workflows/go.yml @@ -0,0 +1,28 @@ +# This workflow will build a golang project +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go + +name: Build Telly + +on: + push: + branches: [ "dev" ] + pull_request: + branches: [ "dev" ] + +jobs: + + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: '1.20' + + - name: Build + run: go build -v ./... + + - name: Test + run: go test -v ./... From 53ddaa8a8c658c5febc0f222aad7fdc4517772d9 Mon Sep 17 00:00:00 2001 From: Chaz Larson Date: Thu, 30 Nov 2023 14:46:16 -0600 Subject: [PATCH 113/114] Create go-2.yml --- .github/workflows/go-2.yml | 117 +++++++++++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 .github/workflows/go-2.yml diff --git a/.github/workflows/go-2.yml b/.github/workflows/go-2.yml new file mode 100644 index 0000000..227d058 --- /dev/null +++ b/.github/workflows/go-2.yml @@ -0,0 +1,117 @@ +on: + push: + # Sequence of patterns matched against refs/tags + tags: + - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10 + +name: Latest Release + +defaults: + run: + shell: bash + +jobs: + lint: + name: Lint files + runs-on: 'ubuntu-latest' + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-go@v2 + with: + go-version: '1.16.3' + - name: golangci-lint + uses: golangci/golangci-lint-action@v2.5.2 + with: + version: latest + test: + name: Run tests + runs-on: 'ubuntu-latest' + needs: lint + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-go@v2 + with: + go-version: '1.16.3' + - run: go test -v -cover + release: + name: Create Release + runs-on: 'ubuntu-latest' + needs: test + strategy: + matrix: + goosarch: + - 'aix/ppc64' + # - 'android/386' + - 'android/amd64' + # - 'android/arm' + - 'android/arm64' + - 'darwin/amd64' + - 'darwin/arm64' + - 'dragonfly/amd64' + - 'freebsd/386' + - 'freebsd/amd64' + - 'freebsd/arm' + - 'freebsd/arm64' + - 'illumos/amd64' + # - 'ios/amd64' + # - 'ios/arm64' + - 'js/wasm' + - 'linux/386' + - 'linux/amd64' + - 'linux/arm' + - 'linux/arm64' + - 'linux/mips' + - 'linux/mips64' + - 'linux/mips64le' + - 'linux/mipsle' + - 'linux/ppc64' + - 'linux/ppc64le' + - 'linux/riscv64' + - 'linux/s390x' + - 'netbsd/386' + - 'netbsd/amd64' + - 'netbsd/arm' + - 'netbsd/arm64' + - 'openbsd/386' + - 'openbsd/amd64' + - 'openbsd/arm' + - 'openbsd/arm64' + - 'openbsd/mips64' + - 'plan9/386' + - 'plan9/amd64' + - 'plan9/arm' + - 'solaris/amd64' + - 'windows/386' + - 'windows/amd64' + - 'windows/arm' + steps: + - name: Checkout code + uses: actions/checkout@v2 + with: + fetch-depth: 0 + - uses: actions/setup-go@v2 + with: + go-version: '1.16.3' + - name: Get OS and arch info + run: | + GOOSARCH=${{matrix.goosarch}} + GOOS=${GOOSARCH%/*} + GOARCH=${GOOSARCH#*/} + BINARY_NAME=${{github.repository}}-$GOOS-$GOARCH + echo "BINARY_NAME=$BINARY_NAME" >> $GITHUB_ENV + echo "GOOS=$GOOS" >> $GITHUB_ENV + echo "GOARCH=$GOARCH" >> $GITHUB_ENV + - name: Build + run: | + go build -o "$BINARY_NAME" -v + - name: Release Notes + run: + git log $(git describe HEAD~ --tags --abbrev=0)..HEAD --pretty='format:* %h %s%n * %an <%ae>' --no-merges >> ".github/RELEASE-TEMPLATE.md" + - name: Release with Notes + uses: softprops/action-gh-release@v1 + with: + body_path: ".github/RELEASE-TEMPLATE.md" + draft: true + files: ${{env.BINARY_NAME}} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 325ffcb3c553319cf9ac1e14baa14527daf80bbf Mon Sep 17 00:00:00 2001 From: Chaz Larson Date: Thu, 30 Nov 2023 14:56:28 -0600 Subject: [PATCH 114/114] go 1.20, remove many platforms. --- .github/workflows/go-2.yml | 74 +++++++++++++++++++------------------- 1 file changed, 37 insertions(+), 37 deletions(-) diff --git a/.github/workflows/go-2.yml b/.github/workflows/go-2.yml index 227d058..e3a2348 100644 --- a/.github/workflows/go-2.yml +++ b/.github/workflows/go-2.yml @@ -18,7 +18,7 @@ jobs: - uses: actions/checkout@v2 - uses: actions/setup-go@v2 with: - go-version: '1.16.3' + go-version: '1.20' - name: golangci-lint uses: golangci/golangci-lint-action@v2.5.2 with: @@ -31,7 +31,7 @@ jobs: - uses: actions/checkout@v2 - uses: actions/setup-go@v2 with: - go-version: '1.16.3' + go-version: '1.20' - run: go test -v -cover release: name: Create Release @@ -40,48 +40,48 @@ jobs: strategy: matrix: goosarch: - - 'aix/ppc64' + # - 'aix/ppc64' # - 'android/386' - - 'android/amd64' + # - 'android/amd64' # - 'android/arm' - - 'android/arm64' + # - 'android/arm64' - 'darwin/amd64' - 'darwin/arm64' - - 'dragonfly/amd64' - - 'freebsd/386' - - 'freebsd/amd64' - - 'freebsd/arm' - - 'freebsd/arm64' - - 'illumos/amd64' + # - 'dragonfly/amd64' + # - 'freebsd/386' + # - 'freebsd/amd64' + # - 'freebsd/arm' + # - 'freebsd/arm64' + # - 'illumos/amd64' # - 'ios/amd64' # - 'ios/arm64' - - 'js/wasm' - - 'linux/386' + # - 'js/wasm' + # - 'linux/386' - 'linux/amd64' - - 'linux/arm' + # - 'linux/arm' - 'linux/arm64' - - 'linux/mips' - - 'linux/mips64' - - 'linux/mips64le' - - 'linux/mipsle' - - 'linux/ppc64' - - 'linux/ppc64le' - - 'linux/riscv64' - - 'linux/s390x' - - 'netbsd/386' - - 'netbsd/amd64' - - 'netbsd/arm' - - 'netbsd/arm64' - - 'openbsd/386' - - 'openbsd/amd64' - - 'openbsd/arm' - - 'openbsd/arm64' - - 'openbsd/mips64' - - 'plan9/386' - - 'plan9/amd64' - - 'plan9/arm' - - 'solaris/amd64' - - 'windows/386' + # - 'linux/mips' + # - 'linux/mips64' + # - 'linux/mips64le' + # - 'linux/mipsle' + # - 'linux/ppc64' + # - 'linux/ppc64le' + # - 'linux/riscv64' + # - 'linux/s390x' + # - 'netbsd/386' + # - 'netbsd/amd64' + # - 'netbsd/arm' + # - 'netbsd/arm64' + # - 'openbsd/386' + # - 'openbsd/amd64' + # - 'openbsd/arm' + # - 'openbsd/arm64' + # - 'openbsd/mips64' + # - 'plan9/386' + # - 'plan9/amd64' + # - 'plan9/arm' + # - 'solaris/amd64' + # - 'windows/386' - 'windows/amd64' - 'windows/arm' steps: @@ -91,7 +91,7 @@ jobs: fetch-depth: 0 - uses: actions/setup-go@v2 with: - go-version: '1.16.3' + go-version: '1.20' - name: Get OS and arch info run: | GOOSARCH=${{matrix.goosarch}}