commit 6fbfa94bbc8be3dffec0be340e2011c83ab8637a Author: Alban Bedel Date: Sat Aug 17 19:15:08 2024 +0200 Initial version of the timetable/countdown Signed-off-by: Alban Bedel diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e884303 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +timetable diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3962ae4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +FROM golang:1.22-alpine as builder +ARG CGO_ENABLED=0 +WORKDIR /app + +COPY go.mod go.sum ./ +RUN go mod download +COPY *.go . + +RUN go build + +FROM gcr.io/distroless/static:nonroot +WORKDIR /app +COPY --from=builder /app/timetable /usr/bin/timetable +COPY timetables.yaml . +COPY static/*.html static/*.css static/*.js static/ + +USER nonroot +ENTRYPOINT ["timetable"] diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d4b26f6 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module git.gaengeviertel.de/IT/timetable + +go 1.22 + +require gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a62c313 --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/static/countdown.css b/static/countdown.css new file mode 100644 index 0000000..cf2bbc3 --- /dev/null +++ b/static/countdown.css @@ -0,0 +1,18 @@ +body { + font-size: 8vw; + background: black; + color: white; +} + +.flash { + background: red; +} + +#view { + position: fixed; + inset: 0; + width: fit-content; + height: fit-content; + margin: auto; + text-align: center; +} diff --git a/static/countdown.html b/static/countdown.html new file mode 100644 index 0000000..3870bc2 --- /dev/null +++ b/static/countdown.html @@ -0,0 +1,18 @@ + + + + Countdown + + + + + +
+
+
+ + +
+
+ + diff --git a/static/countdown.js b/static/countdown.js new file mode 100644 index 0000000..fd5552e --- /dev/null +++ b/static/countdown.js @@ -0,0 +1,153 @@ +var urlParams = new URLSearchParams(window.location.search); +var path = window.location.pathname.split("/"); +var loc = path[path.length - 1]; +var url = "/timetable/" + loc; +var timetable = {}; +var timeOffset = Number.parseInt(urlParams.get("timeOffset")); +var flashDelay = urlParams.has("flashDelay") ? Number.parseInt(urlParams.get("flashDelay")) : 500; +var flashStart = urlParams.has("flashStart") ? Number.parseInt(urlParams.get("flashStart")) : 5*60; +var flashElement = "counter"; +var lastFlash = null; +var locale = "de-DE"; + +var shortTime = new Intl.DateTimeFormat(locale, { timeStyle: "short" }) + +function setTimetable(tt) { + timetable = tt; + updateCounter(); +} + +function getShow(tt, when) { + if (tt.shows == null) { + return null; + } + + for (let i = 0; i < timetable.shows.length; i++) { + let show = tt.shows[i]; + let start = new Date(show.start); + + // Show has not yet started + if (when < start.getTime()) + return null; + + // No end + if (show.end == null) { + return show; + } + + // Has it ended? + let end = new Date(show.end); + if (when < end.getTime()) + return show; + } + + return null; +} + +function getNextShow(tt, when) { + if (tt.shows == null) { + return null; + } + + for (let i = 0; i < timetable.shows.length; i++) { + let show = tt.shows[i]; + let start = new Date(show.start); + + // Return the first show that has not yet started + if (when < start.getTime()) + return show; + } + + return null; +} + +function stopFlash() { + let elem = document.getElementById(flashElement); + if (elem === null) + return; + + elem.classList.remove("flash"); + lastFlash = null; +} + +function doFlash(now) { + let elem = document.getElementById(flashElement); + if (elem === null) + return; + + if (lastFlash === null) { + lastFlash = now; + } + + if (now - lastFlash >= flashDelay) { + elem.classList.toggle("flash"); + lastFlash = now; + } +} + +function updateCounter() { + let artistSpan = document.getElementById("artist"); + let valueSpan = document.getElementById("counter-value"); + let unitSpan = document.getElementById("counter-unit"); + + if (artistSpan === null || valueSpan === null || unitSpan === null) + return; + + let now = new Date(); + if (timeOffset > 0) { + now = new Date(now.getTime() + timeOffset*60000); + } + + let show = getShow(timetable, now.getTime()); + if (show == null) { + show = getNextShow(timetable, now.getTime()); + if (show != null) { + let start = new Date(show.start); + + artistSpan.textContent = "Next: " + show.name; + valueSpan.textContent = shortTime.format(start); + } else { + artistSpan.textContent = ""; + } + unitSpan.textContent = ""; + stopFlash(); + return; + } + + let value = ""; + let unit = "" + if (show.end != null) { + let end = new Date(show.end); + let sec = Math.round((end - now) / 1000); + if (sec >= 60) { + value = Math.ceil(sec / 60).toString(); + unit = "m"; + } else { + value = sec.toString(); + unit = "s"; + } + + if (sec < flashStart) + doFlash(now); + else + stopFlash(); + } else { + stopFlash(); + } + + artistSpan.textContent = show.name; + valueSpan.textContent = value; + unitSpan.textContent = unit; +} +setInterval(updateCounter, 100); + +function updateTimetable() { + console.log("Updating timetable"); + fetch(url, { signal: AbortSignal.timeout(5000) }) + .then(res => res.json()) + .then(tt => setTimetable(tt)) + .catch(err => console.log(err)); +} +setInterval(updateTimetable, 2*60*1000); + +updateTimetable(); diff --git a/timetable.go b/timetable.go new file mode 100644 index 0000000..e2cdbbb --- /dev/null +++ b/timetable.go @@ -0,0 +1,286 @@ +package main + +import ( + "context" + "encoding/csv" + "encoding/json" + "fmt" + "log" + "net/http" + "os" + "path" + "regexp" + "strconv" + "time" + + "gopkg.in/yaml.v3" +) + +type Show struct { + Name string `json:"name"` + Start time.Time `json:"start"` + End *time.Time `json:"end,omitempty"` +} + +type TimeTable struct { + Title string `json:"title"` + Location string `json:"location"` + Shows []Show `json:"shows"` +} + +func (tt TimeTable) findShowAt(when time.Time) (int) { + for i, s := range(tt.Shows) { + if when.After(s.Start) && (s.End == nil || when.Before(*s.End)) { + return i + } + } + return -1 +} + +func (tt TimeTable) ShowAt(when time.Time) (Show, bool) { + if idx := tt.findShowAt(when); idx >= 0 { + return tt.Shows[idx], true + } else { + return Show{}, false + } +} + +func (tt TimeTable) CurrentShow() (Show, bool) { + return tt.ShowAt(time.Now()) +} + +func (tt TimeTable) NextShowAt(when time.Time) (Show, bool) { + if idx := tt.findShowAt(when); idx >= 0 && (idx+1) < len(tt.Shows) { + return tt.Shows[idx], true + } else { + return Show{}, false + } +} + +func (tt TimeTable) NextShow() (Show, bool) { + return tt.NextShowAt(time.Now()) +} + +type TimeTableSource struct { + ID string `yaml:"id"` + URL string `yaml:"url"` + Title string `yaml:"title,omitempty"` + Location string `yaml:"location,omitempty"` + StartDate string `yaml:"startDate,omitempty"` + TimeColumn int `yaml:"timeColumn"` + NameColumn int `yaml:"nameColumn"` + CountdownPath string `yaml:"countDownPath"` +} + +type Config struct { + Listen string `yaml:"listen"` + StaticDir string `yaml:"staticdir"` + Sources []TimeTableSource `yaml:"sources,omitempty"` +} + +func getCSV(src string, ctx context.Context) ([][]string, error) { + req, err := http.NewRequestWithContext(ctx, "GET", src, nil) + if err != nil { + return nil, err + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + + if resp.StatusCode > 299 { + return nil, fmt.Errorf("HTTP error %d: %s", resp.StatusCode, resp.Status) + } + + ct := resp.Header.Get("Content-Type") + if ct != "text/csv" && ct != "" { + return nil, fmt.Errorf("Content is not CSV: %s", ct) + } + + return csv.NewReader(resp.Body).ReadAll() +} + +func timeAtHour(t time.Time, h int) time.Time { + return time.Date(t.Year(), t.Month(), t.Day(), h, 0, 0, 0, t.Location()) +} + +func (src *TimeTableSource) GetTimetable(ctx context.Context) (TimeTable, error) { + records, err := getCSV(src.URL, ctx) + if err != nil { + return TimeTable{}, err + } + + start, err := time.ParseInLocation(time.DateOnly, src.StartDate, time.Local) + if err != nil { + return TimeTable{}, err + } + + tt := TimeTable{ + Title: src.Title, + Location: src.Location, + } + + numColumns := src.TimeColumn + 1 + if src.NameColumn > src.TimeColumn { + numColumns = src.NameColumn + 1 + } + + timeSlotRe := regexp.MustCompile(`([0-9]+)-([0-9]+)?`) + for _, row := range(records) { + // Check that we have enought columns + if (len(row) < numColumns) { + continue + } + + // Parse the time slot, skip if invalid + timeSlot := timeSlotRe.FindStringSubmatch(row[src.TimeColumn]) + if len(timeSlot) < 2 { + continue + } + + startHour, err := strconv.Atoi(timeSlot[1]) + if err != nil { + continue + } + + start = timeAtHour(start, startHour) + end := start + + if len(timeSlot) > 2 { + endHour, err := strconv.Atoi(timeSlot[2]) + if err != nil { + continue + } + if endHour < startHour { + end = end.Add(time.Hour * 24) + } + end = timeAtHour(end, endHour) + } + + show := Show{ + Name: row[src.NameColumn], + Start: start, + } + + if end != start { + show.End = &end + } + + if show.Name != "" { + tt.Shows = append(tt.Shows, show) + } + + start = end + } + + return tt, nil +} + +func (src TimeTableSource) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // Early validity check + switch when := r.PathValue("when"); when { + case "": + case "now": + case "next": + case "first": + default: + http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) + } + + ctx, cancel := context.WithTimeout(r.Context(), time.Second*10) + defer cancel() + + tt, err := src.GetTimetable(ctx) + if err != nil { + log.Printf("Failed to get timetable: %s", err) + http.Error(w, http.StatusText(http.StatusBadGateway), http.StatusBadGateway) + return + } + + var data any + found := true + switch when := r.PathValue("when"); when { + case "": + data = tt + case "now": + data, found = tt.CurrentShow() + case "next": + data, found = tt.NextShow() + case "first": + if len(tt.Shows) > 0 { + data = tt.Shows[0] + } else { + found = false + } + default: + http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/json") + + // Send an empty object if nothing was found + if !found { + data = make(map[string]string) + } + json.NewEncoder(w).Encode(data) +} + +func LoadConfig(path string) (Config, error) { + rd, err := os.Open(path) + if err != nil { + return Config{}, fmt.Errorf("Failed to open config: %w", err) + } + defer rd.Close() + + var cfg Config + err = yaml.NewDecoder(rd).Decode(&cfg) + if err != nil { + return Config{}, fmt.Errorf("Failed to parse config: %w", err) + } + + return cfg, nil +} + +func main() { + cfgFile := "timetables.yaml" + + cfg, err := LoadConfig(cfgFile) + if err != nil { + log.Fatal(err) + } + + staticDir := cfg.StaticDir + if staticDir == "" { + staticDir = "static" + } + + http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir(staticDir)))) + + for _, src := range(cfg.Sources) { + if src.ID == "" || src.URL == "" { + log.Printf("Skipping invalid source: %s\n", src) + continue + } + log.Printf("Registering timetable %s\n", src.ID) + + if src.CountdownPath == "" { + src.CountdownPath = "countdown.html" + } + + http.Handle(fmt.Sprintf("/timetable/%s", src.ID), src) + http.Handle(fmt.Sprintf("/timetable/%s/{when}", src.ID), src) + http.HandleFunc(fmt.Sprintf("/countdown/%s", src.ID), + func (w http.ResponseWriter, req *http.Request) { + http.ServeFile(w, req, path.Join(staticDir, src.CountdownPath)) + }) + } + + listen := cfg.Listen + if listen == "" { + listen = ":8080" + } + + log.Fatal(http.ListenAndServe(listen, nil)) +} diff --git a/timetables.yaml b/timetables.yaml new file mode 100644 index 0000000..5b6edb0 --- /dev/null +++ b/timetables.yaml @@ -0,0 +1,10 @@ +timeZone: Europe/Berlin + +sources: + - id: ladoens + title: 15 Jahre Gänge 5 Jahre Galerie LADØNS DJ-Marathon + location: Galerie LADØNS + url: https://docs.google.com/spreadsheets/d/1gxQC-veUu_3PU1QbQDkXAGnELVbDrriLWYk9Zygu_-s/export?format=csv + startDate: 2024-08-24 + timeColumn: 0 + nameColumn: 1