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)
+}