Initial version of the timetable/countdown
Signed-off-by: Alban Bedel <albeu@free.fr>
This commit is contained in:
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))
|
||||
}
|
||||
Reference in New Issue
Block a user