mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 10:42:57 +00:00
600 lines
23 KiB
Go
600 lines
23 KiB
Go
package controllers
|
|
|
|
import (
|
|
"net/http"
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
"fmt"
|
|
"strings"
|
|
"io"
|
|
"image"
|
|
_ "image/png"
|
|
_ "image/jpeg"
|
|
_ "image/gif"
|
|
"net/http/httputil"
|
|
|
|
"fotbal-club/internal/models"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// ScoreboardController manages the singleton scoreboard state
|
|
type ScoreboardController struct {
|
|
DB *gorm.DB
|
|
}
|
|
|
|
// --- Additional features ported from MyClub ScoreBoard ---
|
|
|
|
// makeShort derives a 3-letter uppercase abbreviation from a club name.
|
|
func makeShort(name string) string {
|
|
name = strings.TrimSpace(name)
|
|
if name == "" { return "---" }
|
|
name = strings.ToUpper(name)
|
|
repl := strings.NewReplacer(
|
|
"Á", "A", "Ä", "A", "Å", "A", "Â", "A", "À", "A",
|
|
"Č", "C", "Ć", "C", "Ç", "C",
|
|
"Ď", "D",
|
|
"É", "E", "Ě", "E", "È", "E", "Ë", "E", "Ê", "E",
|
|
"Í", "I", "Ì", "I", "Ï", "I", "Î", "I",
|
|
"Ň", "N", "Ń", "N",
|
|
"Ó", "O", "Ö", "O", "Ô", "O", "Ò", "O",
|
|
"Ř", "R",
|
|
"Š", "S", "Ś", "S",
|
|
"Ť", "T",
|
|
"Ú", "U", "Ů", "U", "Ù", "U", "Ü", "U", "Û", "U",
|
|
"Ý", "Y",
|
|
"Ž", "Z",
|
|
)
|
|
name = repl.Replace(name)
|
|
out := make([]rune, 0, 3)
|
|
for _, r := range name {
|
|
if r >= 'A' && r <= 'Z' {
|
|
out = append(out, r)
|
|
if len(out) == 3 { break }
|
|
}
|
|
}
|
|
for len(out) < 3 { out = append(out, '-') }
|
|
return string(out)
|
|
}
|
|
|
|
// DeriveColors returns average dominant colors from provided logo URLs
|
|
func (c *ScoreboardController) DeriveColors(ctx *gin.Context) {
|
|
type req struct {
|
|
URL string `json:"url"`
|
|
HomeLogo string `json:"homeLogo"`
|
|
AwayLogo string `json:"awayLogo"`
|
|
}
|
|
type singleResp struct{ Color string `json:"color"` }
|
|
type duoResp struct{ PrimaryColor string `json:"primaryColor"`; SecondaryColor string `json:"secondaryColor"` }
|
|
|
|
var q req
|
|
q.URL = ctx.Query("url")
|
|
q.HomeLogo = ctx.Query("homeLogo")
|
|
q.AwayLogo = ctx.Query("awayLogo")
|
|
if q.URL == "" && q.HomeLogo == "" && q.AwayLogo == "" {
|
|
// try JSON body
|
|
_ = ctx.ShouldBindJSON(&q)
|
|
}
|
|
|
|
if q.URL != "" {
|
|
col, err := averageColorFromURL(q.URL)
|
|
if err != nil {
|
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "cannot fetch or process image"})
|
|
return
|
|
}
|
|
ctx.JSON(http.StatusOK, singleResp{Color: col})
|
|
return
|
|
}
|
|
if q.HomeLogo != "" || q.AwayLogo != "" {
|
|
var primary, secondary string
|
|
if q.HomeLogo != "" {
|
|
if col, err := averageColorFromURL(q.HomeLogo); err == nil { primary = col }
|
|
}
|
|
if q.AwayLogo != "" {
|
|
if col, err := averageColorFromURL(q.AwayLogo); err == nil { secondary = col }
|
|
}
|
|
ctx.JSON(http.StatusOK, duoResp{PrimaryColor: primary, SecondaryColor: secondary})
|
|
return
|
|
}
|
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "provide ?url= or ?homeLogo=&awayLogo="})
|
|
}
|
|
|
|
// averageColorFromURL downloads an image and computes its average RGB color in hex.
|
|
func averageColorFromURL(u string) (string, error) {
|
|
resp, err := http.Get(u)
|
|
if err != nil { return "", err }
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
// best-effort body capture for debugging
|
|
b, _ := httputil.DumpResponse(resp, false)
|
|
_ = b
|
|
return "", fmt.Errorf("http status %d", resp.StatusCode)
|
|
}
|
|
img, _, err := image.Decode(resp.Body)
|
|
if err != nil { return "", err }
|
|
return averageHex(img), nil
|
|
}
|
|
|
|
func averageHex(img image.Image) string {
|
|
rect := img.Bounds()
|
|
if rect.Empty() { return "#000000" }
|
|
w := rect.Dx(); h := rect.Dy()
|
|
stepX, stepY := 1, 1
|
|
for (w/stepX)*(h/stepY) > 160000 {
|
|
if stepX <= stepY { stepX *= 2 } else { stepY *= 2 }
|
|
}
|
|
var rsum, gsum, bsum, count uint64
|
|
for y := rect.Min.Y; y < rect.Max.Y; y += stepY {
|
|
for x := rect.Min.X; x < rect.Max.X; x += stepX {
|
|
cr, cg, cb, ca := img.At(x,y).RGBA()
|
|
if ca < 0x2000 { continue }
|
|
rsum += uint64(cr >> 8)
|
|
gsum += uint64(cg >> 8)
|
|
bsum += uint64(cb >> 8)
|
|
count++
|
|
}
|
|
}
|
|
if count == 0 { return "#000000" }
|
|
r8 := uint8(rsum / count)
|
|
g8 := uint8(gsum / count)
|
|
b8 := uint8(bsum / count)
|
|
return fmt.Sprintf("#%02x%02x%02x", r8, g8, b8)
|
|
}
|
|
|
|
// SwapSides swaps home and away team info including names, logos, shorts, scores and colors.
|
|
func (c *ScoreboardController) SwapSides(ctx *gin.Context) {
|
|
s, err := c.getOrCreateSingleton()
|
|
if err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": "cannot load scoreboard"}); return }
|
|
s.HomeName, s.AwayName = s.AwayName, s.HomeName
|
|
s.HomeLogoURL, s.AwayLogoURL = s.AwayLogoURL, s.HomeLogoURL
|
|
s.HomeScore, s.AwayScore = s.AwayScore, s.HomeScore
|
|
s.HomeShort, s.AwayShort = s.AwayShort, s.HomeShort
|
|
s.PrimaryColor, s.SecondaryColor = s.SecondaryColor, s.PrimaryColor
|
|
if err := c.DB.Save(s).Error; err != nil {
|
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save"}); return
|
|
}
|
|
ctx.JSON(http.StatusOK, gin.H{"ok": true})
|
|
}
|
|
|
|
// StartSecondHalf swaps sides, resets the timer to 00:00 and immediately starts it.
|
|
func (c *ScoreboardController) StartSecondHalf(ctx *gin.Context) {
|
|
s, err := c.getOrCreateSingleton()
|
|
if err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": "cannot load scoreboard"}); return }
|
|
// swap first
|
|
s.HomeName, s.AwayName = s.AwayName, s.HomeName
|
|
s.HomeLogoURL, s.AwayLogoURL = s.AwayLogoURL, s.HomeLogoURL
|
|
s.HomeScore, s.AwayScore = s.AwayScore, s.HomeScore
|
|
s.HomeShort, s.AwayShort = s.AwayShort, s.HomeShort
|
|
s.PrimaryColor, s.SecondaryColor = s.SecondaryColor, s.PrimaryColor
|
|
// reset and start timer for next half
|
|
s.Running = true
|
|
s.ElapsedSeconds = 0
|
|
s.TimerStartUnix = time.Now().Unix()
|
|
s.Timer = "00:00"
|
|
if err := c.DB.Save(s).Error; err != nil {
|
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save"}); return
|
|
}
|
|
ctx.JSON(http.StatusOK, gin.H{"ok": true})
|
|
}
|
|
|
|
// SaveState saves current scoreboard state as a JSON file in /saved directory.
|
|
func (c *ScoreboardController) SaveState(ctx *gin.Context) {
|
|
type req struct{ Filename string `json:"filename"` }
|
|
var q req
|
|
_ = ctx.ShouldBindJSON(&q)
|
|
if q.Filename == "" { q.Filename = ctx.Query("filename") }
|
|
name := sanitizeFilename(q.Filename)
|
|
if name == "" { name = time.Now().Format("20060102-150405") }
|
|
if !strings.HasSuffix(strings.ToLower(name), ".json") { name += ".json" }
|
|
_ = os.MkdirAll("saved", 0o755)
|
|
s, err := c.getOrCreateSingleton()
|
|
if err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": "cannot load scoreboard"}); return }
|
|
b, _ := json.MarshalIndent(s, "", " ")
|
|
if err := os.WriteFile(filepath.Join("saved", name), b, 0o644); err != nil {
|
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "save failed"}); return
|
|
}
|
|
ctx.JSON(http.StatusOK, gin.H{"saved": name})
|
|
}
|
|
|
|
// ListSaves returns the list of saved preset filenames from /saved
|
|
func (c *ScoreboardController) ListSaves(ctx *gin.Context) {
|
|
entries, err := os.ReadDir("saved")
|
|
if err != nil { ctx.JSON(http.StatusOK, []string{}) ; return }
|
|
out := make([]string, 0, len(entries))
|
|
for _, e := range entries {
|
|
if e.IsDir() { continue }
|
|
name := e.Name()
|
|
if strings.HasSuffix(strings.ToLower(name), ".json") { out = append(out, name) }
|
|
}
|
|
ctx.JSON(http.StatusOK, out)
|
|
}
|
|
|
|
// LoadSaved loads a saved preset from /saved/<filename> and applies it to the singleton.
|
|
func (c *ScoreboardController) LoadSaved(ctx *gin.Context) {
|
|
// Support filename via query, JSON, or multipart form file upload as raw JSON
|
|
filename := sanitizeFilename(ctx.Query("filename"))
|
|
var body struct{ Filename string `json:"filename"` }
|
|
if filename == "" {
|
|
_ = ctx.ShouldBindJSON(&body)
|
|
filename = sanitizeFilename(body.Filename)
|
|
}
|
|
if filename == "" {
|
|
// try multipart file upload under field "file"
|
|
file, _, err := ctx.Request.FormFile("file")
|
|
if err == nil {
|
|
defer file.Close()
|
|
data, err := io.ReadAll(file)
|
|
if err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": "cannot read file"}); return }
|
|
var imported models.ScoreboardState
|
|
if err := json.Unmarshal(data, &imported); err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON"}); return }
|
|
applyImportedState(imported, c, ctx)
|
|
return
|
|
}
|
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "missing filename"})
|
|
return
|
|
}
|
|
if !strings.HasSuffix(strings.ToLower(filename), ".json") { filename += ".json" }
|
|
path := filepath.Join("saved", filename)
|
|
data, err := os.ReadFile(path)
|
|
if err != nil { ctx.JSON(http.StatusNotFound, gin.H{"error": "file not found"}); return }
|
|
var imported models.ScoreboardState
|
|
if err := json.Unmarshal(data, &imported); err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON"}); return }
|
|
applyImportedState(imported, c, ctx)
|
|
}
|
|
|
|
func applyImportedState(imported models.ScoreboardState, c *ScoreboardController, ctx *gin.Context) {
|
|
s, err := c.getOrCreateSingleton()
|
|
if err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": "cannot load scoreboard"}); return }
|
|
// overwrite relevant fields
|
|
s.HomeName = imported.HomeName
|
|
s.AwayName = imported.AwayName
|
|
s.HomeLogoURL = imported.HomeLogoURL
|
|
s.AwayLogoURL = imported.AwayLogoURL
|
|
// derive shorts if empty
|
|
if strings.TrimSpace(imported.HomeShort) != "" { s.HomeShort = imported.HomeShort } else { s.HomeShort = makeShort(s.HomeName) }
|
|
if strings.TrimSpace(imported.AwayShort) != "" { s.AwayShort = imported.AwayShort } else { s.AwayShort = makeShort(s.AwayName) }
|
|
if imported.PrimaryColor != "" { s.PrimaryColor = imported.PrimaryColor }
|
|
if imported.SecondaryColor != "" { s.SecondaryColor = imported.SecondaryColor }
|
|
s.HomeScore = imported.HomeScore
|
|
s.AwayScore = imported.AwayScore
|
|
if imported.HalfLength > 0 { s.HalfLength = imported.HalfLength }
|
|
if imported.Theme != "" { s.Theme = imported.Theme }
|
|
// timer handling
|
|
base := parseTimerToSeconds(imported.Timer)
|
|
s.Timer = fmt.Sprintf("%02d:%02d", base/60, base%60)
|
|
s.ElapsedSeconds = base
|
|
if imported.Running {
|
|
s.Running = true
|
|
s.TimerStartUnix = time.Now().Unix() - int64(base)
|
|
} else {
|
|
s.Running = false
|
|
s.TimerStartUnix = 0
|
|
}
|
|
if err := c.DB.Save(s).Error; err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save"}); return }
|
|
ctx.JSON(http.StatusOK, gin.H{"ok": true})
|
|
}
|
|
|
|
// sanitizeFilename keeps only [a-zA-Z0-9-_] and dots, strips path separators
|
|
func sanitizeFilename(in string) string {
|
|
in = strings.TrimSpace(in)
|
|
in = strings.ReplaceAll(in, "\\", "")
|
|
in = strings.ReplaceAll(in, "/", "")
|
|
var b strings.Builder
|
|
for _, r := range in {
|
|
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.' {
|
|
b.WriteRune(r)
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// --- Timer helpers and handlers ---
|
|
|
|
func parseTimerToSeconds(timer string) int {
|
|
// Expect MM:SS
|
|
if len(timer) < 4 {
|
|
return 0
|
|
}
|
|
var m, s int
|
|
_, err := fmt.Sscanf(timer, "%d:%d", &m, &s)
|
|
if err != nil || m < 0 || s < 0 || s >= 60 {
|
|
return 0
|
|
}
|
|
return m*60 + s
|
|
}
|
|
|
|
func formatSeconds(sec int) string {
|
|
if sec < 0 { sec = 0 }
|
|
return fmt.Sprintf("%02d:%02d", sec/60, sec%60)
|
|
}
|
|
|
|
func computeTimer(s models.ScoreboardState) (timer string, running bool) {
|
|
running = s.Running
|
|
base := s.ElapsedSeconds
|
|
if s.Running {
|
|
now := time.Now().Unix()
|
|
if s.TimerStartUnix > 0 {
|
|
diff := int(now - s.TimerStartUnix)
|
|
if diff > 0 { base = diff } else { base = 0 }
|
|
}
|
|
}
|
|
// Cap by half length
|
|
cap := s.HalfLength * 60
|
|
if cap <= 0 { cap = 45 * 60 }
|
|
if base >= cap {
|
|
base = cap
|
|
running = false
|
|
}
|
|
timer = formatSeconds(base)
|
|
return
|
|
}
|
|
|
|
// StartTimer sets running=true and backdates TimerStartUnix
|
|
func (c *ScoreboardController) StartTimer(ctx *gin.Context) {
|
|
s, err := c.getOrCreateSingleton()
|
|
if err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": "cannot load scoreboard"}); return }
|
|
if s.ElapsedSeconds == 0 && s.Timer != "" {
|
|
s.ElapsedSeconds = parseTimerToSeconds(s.Timer)
|
|
}
|
|
s.TimerStartUnix = time.Now().Unix() - int64(s.ElapsedSeconds)
|
|
s.Running = true
|
|
if err := c.DB.Save(s).Error; err != nil {
|
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save"}); return
|
|
}
|
|
ctx.JSON(http.StatusOK, gin.H{"ok": true})
|
|
}
|
|
|
|
// PauseTimer sets running=false and fixes elapsedSeconds
|
|
func (c *ScoreboardController) PauseTimer(ctx *gin.Context) {
|
|
s, err := c.getOrCreateSingleton()
|
|
if err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": "cannot load scoreboard"}); return }
|
|
if s.Running {
|
|
now := time.Now().Unix()
|
|
if s.TimerStartUnix > 0 {
|
|
diff := int(now - s.TimerStartUnix)
|
|
if diff > 0 { s.ElapsedSeconds = diff } else { s.ElapsedSeconds = 0 }
|
|
}
|
|
}
|
|
s.Running = false
|
|
// Cap and set display string
|
|
cap := s.HalfLength * 60
|
|
if cap <= 0 { cap = 45 * 60 }
|
|
if s.ElapsedSeconds > cap { s.ElapsedSeconds = cap }
|
|
s.Timer = formatSeconds(s.ElapsedSeconds)
|
|
if err := c.DB.Save(s).Error; err != nil {
|
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save"}); return
|
|
}
|
|
ctx.JSON(http.StatusOK, gin.H{"ok": true})
|
|
}
|
|
|
|
// ResetTimer clears timer to 00:00 and stops it
|
|
func (c *ScoreboardController) ResetTimer(ctx *gin.Context) {
|
|
s, err := c.getOrCreateSingleton()
|
|
if err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": "cannot load scoreboard"}); return }
|
|
s.Running = false
|
|
s.ElapsedSeconds = 0
|
|
s.TimerStartUnix = 0
|
|
s.Timer = "00:00"
|
|
if err := c.DB.Save(s).Error; err != nil {
|
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save"}); return
|
|
}
|
|
ctx.JSON(http.StatusOK, gin.H{"ok": true})
|
|
}
|
|
|
|
func NewScoreboardController(db *gorm.DB) *ScoreboardController {
|
|
return &ScoreboardController{DB: db}
|
|
}
|
|
|
|
func (c *ScoreboardController) getOrCreateSingleton() (*models.ScoreboardState, error) {
|
|
var s models.ScoreboardState
|
|
if err := c.DB.First(&s).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
// Create with sensible defaults
|
|
s = models.ScoreboardState{
|
|
HomeName: "DOMÁCÍ",
|
|
AwayName: "HOSTÉ",
|
|
HomeShort: "DOM",
|
|
AwayShort: "HOS",
|
|
PrimaryColor: "#1e3a8a",
|
|
SecondaryColor: "#2563eb",
|
|
HalfLength: 45,
|
|
Theme: "pill",
|
|
Timer: "00:00",
|
|
Running: false,
|
|
TimerStartUnix: 0,
|
|
ElapsedSeconds: 0,
|
|
// New fields defaults
|
|
SidesFlipped: false,
|
|
Half: 1,
|
|
QRShowEveryMinutes: 5,
|
|
QRShowDurationSeconds: 60,
|
|
}
|
|
if err := c.DB.Create(&s).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return &s, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
// Ensure defaults for newly added fields when loading existing row
|
|
changed := false
|
|
if s.Half == 0 { s.Half = 1; changed = true }
|
|
if s.QRShowEveryMinutes == 0 { s.QRShowEveryMinutes = 5; changed = true }
|
|
if s.QRShowDurationSeconds == 0 { s.QRShowDurationSeconds = 60; changed = true }
|
|
if changed { _ = c.DB.Save(&s).Error }
|
|
return &s, nil
|
|
}
|
|
|
|
// GetPublic returns read-only scoreboard state for public overlay
|
|
func (c *ScoreboardController) GetPublic(ctx *gin.Context) {
|
|
s, err := c.getOrCreateSingleton()
|
|
if err != nil {
|
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "cannot load scoreboard"})
|
|
return
|
|
}
|
|
timer, running := computeTimer(*s)
|
|
ctx.JSON(http.StatusOK, gin.H{
|
|
"homeName": s.HomeName,
|
|
"awayName": s.AwayName,
|
|
"homeLogo": s.HomeLogoURL,
|
|
"awayLogo": s.AwayLogoURL,
|
|
"homeShort": s.HomeShort,
|
|
"awayShort": s.AwayShort,
|
|
"primaryColor": s.PrimaryColor,
|
|
"secondaryColor": s.SecondaryColor,
|
|
"homeScore": s.HomeScore,
|
|
"awayScore": s.AwayScore,
|
|
"halfLength": s.HalfLength,
|
|
"theme": s.Theme,
|
|
"external_match_id": s.ExternalMatchID,
|
|
"active": s.Active,
|
|
// Newly exposed fields compatible with MyClub ScoreBoard overlay
|
|
"sidesFlipped": s.SidesFlipped,
|
|
"half": s.Half,
|
|
"qrEvery": s.QRShowEveryMinutes,
|
|
"qrDuration": s.QRShowDurationSeconds,
|
|
"timer": timer,
|
|
"running": running,
|
|
"timer_start_unix": s.TimerStartUnix,
|
|
"elapsed_seconds": s.ElapsedSeconds,
|
|
})
|
|
}
|
|
|
|
// GetAdmin returns full state for admins
|
|
func (c *ScoreboardController) GetAdmin(ctx *gin.Context) {
|
|
s, err := c.getOrCreateSingleton()
|
|
if err != nil {
|
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "cannot load scoreboard"})
|
|
return
|
|
}
|
|
// Compute transient timer display
|
|
timer, running := computeTimer(*s)
|
|
out := *s
|
|
out.Timer = timer
|
|
out.Running = running
|
|
ctx.JSON(http.StatusOK, out)
|
|
}
|
|
|
|
// PutAdmin updates the singleton state (admin only)
|
|
func (c *ScoreboardController) PutAdmin(ctx *gin.Context) {
|
|
var payload struct {
|
|
HomeName *string `json:"homeName"`
|
|
AwayName *string `json:"awayName"`
|
|
HomeLogo *string `json:"homeLogo"`
|
|
AwayLogo *string `json:"awayLogo"`
|
|
HomeShort *string `json:"homeShort"`
|
|
AwayShort *string `json:"awayShort"`
|
|
PrimaryColor *string `json:"primaryColor"`
|
|
SecondaryColor *string `json:"secondaryColor"`
|
|
HomeScore *int `json:"homeScore"`
|
|
AwayScore *int `json:"awayScore"`
|
|
HalfLength *int `json:"halfLength"`
|
|
Theme *string `json:"theme"`
|
|
ExternalMatchID *string `json:"externalMatchId"`
|
|
Active *bool `json:"active"`
|
|
// Optional direct timer patch (when not running)
|
|
Timer *string `json:"timer"`
|
|
// Newly supported fields (ported from MyClub ScoreBoard)
|
|
SidesFlipped *bool `json:"sidesFlipped"`
|
|
Half *int `json:"half"`
|
|
QRShowEveryMinutes *int `json:"qrEvery"`
|
|
QRShowDurationSeconds *int `json:"qrDuration"`
|
|
}
|
|
if err := ctx.ShouldBindJSON(&payload); err != nil {
|
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
|
|
return
|
|
}
|
|
s, err := c.getOrCreateSingleton()
|
|
if err != nil {
|
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "cannot load scoreboard"})
|
|
return
|
|
}
|
|
// Apply patch
|
|
if payload.HomeName != nil { s.HomeName = *payload.HomeName }
|
|
if payload.AwayName != nil { s.AwayName = *payload.AwayName }
|
|
if payload.HomeLogo != nil { s.HomeLogoURL = *payload.HomeLogo }
|
|
if payload.AwayLogo != nil { s.AwayLogoURL = *payload.AwayLogo }
|
|
if payload.HomeShort != nil { s.HomeShort = *payload.HomeShort }
|
|
if payload.AwayShort != nil { s.AwayShort = *payload.AwayShort }
|
|
if payload.PrimaryColor != nil { s.PrimaryColor = *payload.PrimaryColor }
|
|
if payload.SecondaryColor != nil { s.SecondaryColor = *payload.SecondaryColor }
|
|
if payload.HomeScore != nil { s.HomeScore = *payload.HomeScore }
|
|
if payload.AwayScore != nil { s.AwayScore = *payload.AwayScore }
|
|
if payload.HalfLength != nil { s.HalfLength = *payload.HalfLength }
|
|
if payload.Theme != nil { s.Theme = *payload.Theme }
|
|
if payload.ExternalMatchID != nil { s.ExternalMatchID = *payload.ExternalMatchID }
|
|
if payload.Active != nil { s.Active = *payload.Active }
|
|
if payload.SidesFlipped != nil { s.SidesFlipped = *payload.SidesFlipped }
|
|
if payload.Half != nil { s.Half = *payload.Half }
|
|
if payload.QRShowEveryMinutes != nil && *payload.QRShowEveryMinutes > 0 { s.QRShowEveryMinutes = *payload.QRShowEveryMinutes }
|
|
if payload.QRShowDurationSeconds != nil && *payload.QRShowDurationSeconds > 0 { s.QRShowDurationSeconds = *payload.QRShowDurationSeconds }
|
|
if payload.Timer != nil && !s.Running {
|
|
// Set base timer string when paused
|
|
s.Timer = *payload.Timer
|
|
s.ElapsedSeconds = parseTimerToSeconds(*payload.Timer)
|
|
}
|
|
|
|
if err := c.DB.Save(s).Error; err != nil {
|
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save"})
|
|
return
|
|
}
|
|
|
|
// Best-effort: if scoreboard active and linked to a match, write live cache
|
|
if s.Active && s.ExternalMatchID != "" {
|
|
go writeLiveScoreboardCache(s)
|
|
}
|
|
ctx.JSON(http.StatusOK, s)
|
|
}
|
|
|
|
// writeLiveScoreboardCache writes cache/live/score_<id>.json and patches cache/prefetch/events_upcoming.json if present
|
|
func writeLiveScoreboardCache(s *models.ScoreboardState) {
|
|
// Ensure directories
|
|
_ = os.MkdirAll(filepath.Join("cache", "live"), 0o755)
|
|
// Write live file
|
|
payload := map[string]any{
|
|
"external_match_id": s.ExternalMatchID,
|
|
"home": s.HomeName,
|
|
"away": s.AwayName,
|
|
"home_logo_url": s.HomeLogoURL,
|
|
"away_logo_url": s.AwayLogoURL,
|
|
"home_score": s.HomeScore,
|
|
"away_score": s.AwayScore,
|
|
"primary_color": s.PrimaryColor,
|
|
"secondary_color": s.SecondaryColor,
|
|
"theme": s.Theme,
|
|
"half_length": s.HalfLength,
|
|
"active": s.Active,
|
|
"updated_at": time.Now().Format(time.RFC3339),
|
|
}
|
|
b, _ := json.MarshalIndent(payload, "", " ")
|
|
tmp := filepath.Join("cache", "live", "score_"+s.ExternalMatchID+".json.tmp")
|
|
dst := filepath.Join("cache", "live", "score_"+s.ExternalMatchID+".json")
|
|
if err := os.WriteFile(tmp, b, 0o644); err == nil { _ = os.Rename(tmp, dst) }
|
|
|
|
// Patch prefetch events if available
|
|
prefetch := filepath.Join("cache", "prefetch", "events_upcoming.json")
|
|
f, err := os.Open(prefetch)
|
|
if err != nil { return }
|
|
defer f.Close()
|
|
var arr []map[string]any
|
|
if err := json.NewDecoder(f).Decode(&arr); err != nil { return }
|
|
for i := range arr {
|
|
id := ""
|
|
if v, ok := arr[i]["match_id"].(string); ok { id = v }
|
|
if id == s.ExternalMatchID {
|
|
arr[i]["score"] = map[string]any{"home": s.HomeScore, "away": s.AwayScore}
|
|
arr[i]["home_logo_url"] = s.HomeLogoURL
|
|
arr[i]["away_logo_url"] = s.AwayLogoURL
|
|
arr[i]["home"] = s.HomeName
|
|
arr[i]["away"] = s.AwayName
|
|
break
|
|
}
|
|
}
|
|
out, _ := json.MarshalIndent(arr, "", " ")
|
|
_ = os.WriteFile(prefetch+".tmp", out, 0o644)
|
|
_ = os.Rename(prefetch+".tmp", prefetch)
|
|
}
|