mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 02:32:57 +00:00
upload
This commit is contained in:
@@ -0,0 +1,599 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user