Initial version of the timetable/countdown
Signed-off-by: Alban Bedel <albeu@free.fr>
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
timetable
|
||||||
18
Dockerfile
Normal file
18
Dockerfile
Normal file
@@ -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"]
|
||||||
5
go.mod
Normal file
5
go.mod
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
module git.gaengeviertel.de/IT/timetable
|
||||||
|
|
||||||
|
go 1.22
|
||||||
|
|
||||||
|
require gopkg.in/yaml.v3 v3.0.1
|
||||||
4
go.sum
Normal file
4
go.sum
Normal file
@@ -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=
|
||||||
18
static/countdown.css
Normal file
18
static/countdown.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
18
static/countdown.html
Normal file
18
static/countdown.html
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Countdown</title>
|
||||||
|
<link rel="stylesheet" href="/static/countdown.css"/>
|
||||||
|
</head>
|
||||||
|
<script src="/static/countdown.js"></script>
|
||||||
|
</script>
|
||||||
|
<body>
|
||||||
|
<div id="view">
|
||||||
|
<div id="artist"></div>
|
||||||
|
<div id="counter">
|
||||||
|
<span id="counter-value"></span>
|
||||||
|
<span id="counter-unit"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
153
static/countdown.js
Normal file
153
static/countdown.js
Normal file
@@ -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();
|
||||||
286
timetable.go
Normal file
286
timetable.go
Normal file
@@ -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))
|
||||||
|
}
|
||||||
10
timetables.yaml
Normal file
10
timetables.yaml
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user