diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..afef2ca --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +FROM golang:1.16.2 as builder + +ENV GO111MODULE=on \ + CGO_ENABLED=0 \ + GOOS=linux \ + GOARCH=amd64 + +WORKDIR /app + +COPY go.mod . +#COPY go.sum . +RUN go mod download + +COPY . /app +RUN go build -o binary . + +FROM alpine:latest + +RUN addgroup -S app && adduser -S app -G app + +WORKDIR /app +COPY --from=builder /app/binary . + +USER app +ENTRYPOINT ["./binary"] \ No newline at end of file diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..43baef2 --- /dev/null +++ b/build.sh @@ -0,0 +1,2 @@ +#/bin/sh +docker build . -t xmltv-exporter diff --git a/cmd/server.go b/cmd/server.go new file mode 100644 index 0000000..4ae2a97 --- /dev/null +++ b/cmd/server.go @@ -0,0 +1,112 @@ +package cmd + +import ( + "fmt" + "log" + "net/http" + "path" + "strings" + "time" + "xmltv-exporter/xmltv" +) + +func ServeEpg() { + + http.HandleFunc("/channels-norway.xml", ChannelListHandler) + http.HandleFunc("/", ChannelHandler) + + if err := http.ListenAndServe(":8080", nil); err != nil { + log.Fatal(err) + } +} + +func ChannelListHandler(w http.ResponseWriter, r *http.Request) { + if !isValid(w, r) { + return + } + + bytes, err := xmltv.GetChannelList() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + _, _ = w.Write(bytes) +} + +func ChannelHandler(w http.ResponseWriter, r *http.Request) { + if !isValid(w, r) { + return + } + + channelId, err := channelId(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + date, err := date(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + bytes, err := xmltv.GetSchedule(channelId, date) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + + _, _ = w.Write(bytes) +} + +/** +Finds channelId from Path +Example input: zebrahd.tv2.no_2021-03-16.xml.gz +*/ +func channelId(r *http.Request) (string, error) { + filename := path.Base(r.URL.Path) + parts := strings.Split(filename, "_") + + if len(parts) == 0 { + return "", fmt.Errorf("channel name could not be found from input '%s'", filename) + } + return parts[0], nil +} + +/** +Finds time.Time from Path +Example input: zebrahd.tv2.no_2021-03-16.xml.gz +*/ +func date(r *http.Request) (time.Time, error) { + filename := path.Base(r.URL.Path) + parts := strings.Split(filename, "_") + + if len(parts) <= 1 { + return time.Time{}, fmt.Errorf("date could not be found from input '%s'", filename) + } + + parts2 := strings.Split(parts[1], ".") + + date, err := time.Parse("2006-01-02", parts2[0]) + if err != nil { + return time.Time{}, fmt.Errorf("date could not be parsed: '%s'", err) + + } + + return date, nil +} + +func isValid(w http.ResponseWriter, r *http.Request) bool { + if r.Method != http.MethodGet { + http.Error(w, "Method is not supported", http.StatusMethodNotAllowed) + return false + } + + if len(r.URL.Path) <= 1 { + http.Error(w, "Not found", http.StatusNotFound) + return false + } + + return true +} diff --git a/cmd/transform.go b/cmd/transform.go new file mode 100644 index 0000000..3f66e20 --- /dev/null +++ b/cmd/transform.go @@ -0,0 +1,28 @@ +package cmd + +import ( + "log" + "time" + "xmltv-exporter/tv2" + "xmltv-exporter/xmltv" +) + +func MapEgp() { + today := time.Now() + futureOneWeek := time.Now().Add(time.Hour * 24 * 3) + + var t = today + for t.Before(futureOneWeek) { + channels := tv2.FetchEpg(today) + xmltv.UpdateAvailableChannels(channels) + + // TODO make this more efficient + for _, channel := range channels { + xmltv.BuildCache(t, channel) + } + + t = t.Add(time.Hour * 24) + log.Printf("date: %s", t) + } + log.Println("Epg cache refreshed") +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..cdf720a --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module xmltv-exporter + +go 1.16 diff --git a/main.go b/main.go new file mode 100644 index 0000000..2127581 --- /dev/null +++ b/main.go @@ -0,0 +1,8 @@ +package main + +import "xmltv-exporter/cmd" + +func main() { + cmd.MapEgp() + cmd.ServeEpg() +} diff --git a/tv2/client.go b/tv2/client.go new file mode 100644 index 0000000..4394aa9 --- /dev/null +++ b/tv2/client.go @@ -0,0 +1,56 @@ +package tv2 + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "log" + "net/http" + "time" +) + +// +// https://rest.tv2.no/epg-dw-rest/epg/program/2021/03/15/ +// +const ( + rootUrl = "https://rest.tv2.no/epg-dw-rest/epg/program/" + userAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:86.0) Gecko/20100101 Firefox/86.0" +) + +func FetchEpg(date time.Time) []Channel { + day := date.Day() + month := date.Month() + year := date.Year() + + datePath := fmt.Sprintf("%d/%d/%d", year, int(month), day) + + client := &http.Client{} + req, err := http.NewRequest(http.MethodGet, rootUrl+datePath, nil) + if err != nil { + log.Fatalln(err) + } + + req.Header.Set("User-Agent", userAgent) + + resp, err := client.Do(req) + if err != nil { + log.Fatalf("unable to get data: %s", err) + } + if resp.StatusCode != 200 { + log.Printf("got unhappy statuscode: %d", resp.StatusCode) + } + + defer resp.Body.Close() + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + log.Fatalln(err) + } + + r := &Response{} + err = json.Unmarshal(body, r) + if err != nil { + log.Fatalf("unable to decode data: %s", err) + } + + return r.Channels +} diff --git a/tv2/types.go b/tv2/types.go new file mode 100644 index 0000000..cbd6c07 --- /dev/null +++ b/tv2/types.go @@ -0,0 +1,44 @@ +package tv2 + +import ( + "time" +) + +type Response struct { + Date string `json:"date"` + Channels []Channel `json:"channel"` +} + +type Channel struct { + Id int `json:"id"` + Name string `json:"name"` + ShortName string `json:"shortName"` + Category string `json:"category"` + Score int `json:"score"` + Tv2Id int `json:"tv2id"` + Programs []Program `json:"program"` +} + +type Program struct { + Id int `json:"id"` + SeriesId string `json:"srsid"` + ProgramId string `json:"programId"` + House string `json:"house"` + Title string `json:"title"` + Start time.Time `json:"start"` + Stop time.Time `json:"stop"` + Episode int `json:"epnr"` + EpisodeTotal int `json:"eptot"` + Season int `json:"season"` + SeriesSynopsis string `json:"srsyn"` + EpisodeSynopsis string `json:"epsyn"` + + Nationality string `json:"natio"` + Genre string `json:"genre"` + ProductionYear int `json:"pyear"` + + IsReplay bool `json:"isrepl"` + IsLive bool `json:"islive"` + IsMovie bool `json:"ismov"` + IsSyn bool `json:"issyn"` +} diff --git a/xmltv/channelmap.go b/xmltv/channelmap.go new file mode 100644 index 0000000..6ffa24f --- /dev/null +++ b/xmltv/channelmap.go @@ -0,0 +1,86 @@ +package xmltv + +var xmlChannelIdMap = map[int]string{ + 1005: "tv2.no", + 1152: "zebra.tv2.no", + 1969: "livsstil.tv2.no", + 1147: "news.tv2.no", + 1153: "sport1.tv2.no", + 1154: "sport2.tv2.no", + 1148: "sportpremium.tv2.no", + 1000: "nrk1.nrk.no", + 1001: "nrk2.nrk.no", + 1112: "nrk3.nrk.no", + 1041: "tv3.no", + 1169: "v4.no", + 1162: "tvnorge.no", + 1101: "max.no", + 1092: "fem.no", + 1176: "vox.no", + 1132: "svt1.svt.se", + 1134: "svt2.svt.se", + 1156: "tv4.se", + 1078: "dr1.dr.dk", + 1079: "dr2.dr.dk", + 1083: "tv2.dk", + 1175: "tv6.no", + 1093: "foxtv.no", + 1050: "bbcworld.no", + 1046: "bbcentertainment.no", + 1048: "bbcknowledge.no", + 1044: "animalplanet.discovery.eu", + 1072: "hd.discovery.no", + 1073: "discovery.no", + 1080: "science.discovery.no", + 1085: "world.discovery.no", + 1094: "historytv.no", + 1105: "natgeo.no", + 1108: "wild.natgeo.no", + 1138: "travelchannel.no", + 1082: "tlc.discovery.no", + 1088: "eurosport.discovery.no", + 1089: "eurosport2.discovery.no", + 1081: "dsf.de", // tysk sportskanal lol + 1091: "extremesports.com", + 1057: "first.cmore.no", + 1059: "series.cmore.no", + 1058: "hits.cmore.no", + 1054: "stars.cmore.no", + 1055: "emotion.cmore.no", + 1060: "sf.cmore.no", + 1062: "tennis.cmore.no", + 1065: "hockey.cmore.no", + 1140: "film.viasat.no", + 9999: "action.film.viasat.no", + 1141: "classic.film.viasat.no", + 1142: "drama.film.viasat.no", + 1143: "family.film.viasat.no", + 1144: "comedy.film.viasat.no", + 1174: "plus.viasport.no", + 1170: "1.viasport.no", + 1173: "2.viasport.no", + 1172: "3.viasport.no", + 1166: "golf.viasat.no", + 1167: "history.viasat.no", + 1165: "explore.viasat.no", + 1168: "nature.viasat.no", + 1103: "mtv.no", + 1163: "vh1.no", + 1164: "classic.vh1.no", + 1113: "super.nrk.no", + 1074: "disneychannel.no", + 1077: "xd.disneychannel.no", // finnes ikke lengre + 1075: "junior.disneychannel.no", + 1110: "nickelodeon.no", + 1051: "boomerang.no", + 1067: "cartoonnetwork.no", + 1043: "aljazeera.eu", + 1071: "cnn.eu", + 1069: "cnbc.eu", + 1129: "skynews.eu", + 1042: "3sat.de", + 1084: "dw.de", + 1178: "rai1.de", + 1124: "rtl.de", + 1158: "tv5.eu", +} diff --git a/xmltv/types.go b/xmltv/types.go new file mode 100644 index 0000000..3ade177 --- /dev/null +++ b/xmltv/types.go @@ -0,0 +1,51 @@ +package xmltv + +import ( + "encoding/xml" +) + +type Response struct { + XMLName xml.Name `xml:"tv"` + GeneratorName string `xml:"generator-info-name,attr"` + GeneratorUrl string `xml:"generator-info-url,attr"` + ChannelList []Channel `xml:"channel,omitempty"` + ProgrammeList []Programme `xml:"programme,omitempty"` +} + +type Channel struct { + XMLName xml.Name `xml:"channel"` + Id string `xml:"id,attr"` + Name string `xml:"display-name"` + BaseUrl string `xml:"base-url"` +} + +type Programme struct { + XMLName xml.Name `xml:"programme"` + Channel string `xml:"channel,attr"` + Start string `xml:"start,attr"` + Stop string `xml:"stop,attr"` + Title string `xml:"title"` + SubTitle string `xml:"sub-title,omitempty"` + Description string `xml:"desc,omitempty"` + EpisodeNum EpisodeNum `xml:"episode-num,omitempty"` + Credits string `xml:"credits,omitempty"` + Date string `xml:"date,omitempty"` + Categories []Category `xml:"category,omitempty"` + Rating []Rating `xml:"rating,omitempty"` +} + +type EpisodeNum struct { + XMLName xml.Name `xml:"episode-num"` + System string `xml:"system,attr"` + EpisodeNum string `xml:",chardata"` +} + +type Rating struct { + System string `xml:"system,attr"` + Value string `xml:"value"` +} + +type Category struct { + Value string `xml:",chardata"` + Lang string `xml:"lang,attr"` +} diff --git a/xmltv/xmltv.go b/xmltv/xmltv.go new file mode 100644 index 0000000..2ec6944 --- /dev/null +++ b/xmltv/xmltv.go @@ -0,0 +1,134 @@ +package xmltv + +import ( + "encoding/xml" + "fmt" + "log" + "time" + "xmltv-exporter/tv2" +) + +const ( + XmltvDateFormat = "20060102150400 -0700" + XmltvEpisodeStd = "xmtv_ns" + GeneratorName = "xmltv.sjurtf.net" + GeneratorUrl = "https://xmltv.sjurtf.net/" + DocHeader = `` +) + +var channelCache []tv2.Channel +var channelGuideMap map[string]map[string][]tv2.Program + +func BuildCache(date time.Time, channel tv2.Channel) { + if channelGuideMap == nil { + channelGuideMap = make(map[string]map[string][]tv2.Program) + } + + dateKey := formatCacheKey(date) + if channelGuideMap[dateKey] == nil { + channelGuideMap[dateKey] = make(map[string][]tv2.Program) + } + + xmlChannelId := xmlChannelIdMap[channel.Id] + if xmlChannelId == "" { + log.Printf("channel %s with id %d is not mapped", channel.Name, channel.Id) + } + channelGuideMap[dateKey][xmlChannelId] = channel.Programs + //log.Printf("updated programs on %s for channel %s ", dateKey, channel.Name) +} + +func UpdateAvailableChannels(channels []tv2.Channel) { + channelCache = channels + log.Println("Updated available channels") +} + +func GetChannelList() ([]byte, error) { + if len(channelCache) == 0 { + return nil, fmt.Errorf("channeldata unavailable") + } + + var channels []Channel + var programs []Programme + for _, c := range channelCache { + channel := Channel{ + Id: xmlChannelIdMap[c.Id], + Name: c.Name, + //BaseUrl: GeneratorUrl, + BaseUrl: "http://host.docker.internal:8080/", + } + channels = append(channels, channel) + } + + return marshall(channels, programs) +} + +func GetSchedule(channelId string, date time.Time) ([]byte, error) { + return marshall(nil, getProgramsForChannel(channelId, date)) +} + +func getProgramsForChannel(channelId string, date time.Time) []Programme { + dateKey := formatCacheKey(date) + guide := channelGuideMap[dateKey][channelId] + log.Printf("fetched guide for channelId %s on %s. Num programs %d", channelId, dateKey, len(guide)) + + var programs []Programme + for _, p := range guide { + + ep := EpisodeNum{ + System: XmltvEpisodeStd, + EpisodeNum: formatEpisode(p.Season, p.Episode, p.EpisodeTotal), + } + + programme := Programme{ + Channel: channelId, + Start: formatTime(p.Start), + Stop: formatTime(p.Stop), + Title: p.Title, + SubTitle: p.Title, + Description: p.EpisodeSynopsis, + EpisodeNum: ep, + } + programs = append(programs, programme) + } + + return programs +} + +func marshall(channels []Channel, programs []Programme) ([]byte, error) { + resp := Response{ + GeneratorName: GeneratorName, + GeneratorUrl: GeneratorUrl, + ChannelList: channels, + ProgrammeList: programs, + } + + bytes, err := xml.Marshal(resp) + if err != nil { + log.Fatalln("unable to marshal") + } + + return append([]byte(DocHeader), bytes...), nil +} + +/* +s.e.p/t +Where s is the season number minus 1. +Where e is the episode number minus 1. +Where p is the part number minus 1. +Where t to the total parts (do not subtract 1) + +so Season 7, Episode 5, Part 1 of 2 would appear as: +6.4.0/2 +*/ +func formatEpisode(s, e, t int) string { + return fmt.Sprintf("%d.%d/%d", s, e, t) +} + +func formatTime(date time.Time) string { + return date.Format(XmltvDateFormat) +} + +func formatCacheKey(date time.Time) string { + y, m, d := date.Date() + return fmt.Sprintf("%d-%s-%d", y, m, d) +}