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 toggles visual sides flipping only. It does NOT swap team data. 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.SidesFlipped = !s.SidesFlipped 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 starts the second half without flipping visual sides and continues timer from end of 1st half. 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 } // Move to second half and continue from end of first half s.Half = 2 // Ensure base elapsed reflects end of first half capFirst := s.HalfLength * 60 if capFirst <= 0 { capFirst = 45 * 60 } base := s.ElapsedSeconds if s.Running && s.TimerStartUnix > 0 { now := time.Now().Unix() diff := int(now - s.TimerStartUnix) if diff > base { base = diff } } if base < capFirst { base = capFirst } s.ElapsedSeconds = base s.Timer = formatSeconds(base) s.Running = true s.TimerStartUnix = time.Now().Unix() - int64(base) 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/ 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 // fouls with clamping clamp := func(v int) int { if v < 0 { return 0 }; if v > 5 { return 5 }; return v } s.HomeFouls = clamp(imported.HomeFouls) s.AwayFouls = clamp(imported.AwayFouls) 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; allow up to 2*half when second half is active cap := s.HalfLength * 60 if cap <= 0 { cap = 45 * 60 } if s.Half >= 2 { cap = s.HalfLength * 120 } 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) } // Respect caps similarly to computeTimer cap := s.HalfLength * 60 if cap <= 0 { cap = 45 * 60 } if s.Half >= 2 { cap = s.HalfLength * 120 } if s.ElapsedSeconds >= cap { // Already at or beyond cap; keep paused at cap s.ElapsedSeconds = cap s.Timer = formatSeconds(s.ElapsedSeconds) s.Running = false s.TimerStartUnix = 0 } else { // Start from current elapsed 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.Half >= 2 { cap = s.HalfLength * 120 } if s.ElapsedSeconds > cap { s.ElapsedSeconds = cap } s.Timer = formatSeconds(s.ElapsedSeconds) // Clear start marker when paused 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}) } // 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, HomeFouls: 0, AwayFouls: 0, } 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 } // Clamp fouls 0..5 and ensure non-negative clamp := func(v int) int { if v < 0 { return 0 }; if v > 5 { return 5 }; return v } nf := clamp(s.HomeFouls) af := clamp(s.AwayFouls) if s.HomeFouls != nf { s.HomeFouls = nf; changed = true } if s.AwayFouls != af { s.AwayFouls = af; 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, "homeFouls": s.HomeFouls, "awayFouls": s.AwayFouls, "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"` HomeFouls *int `json:"homeFouls"` AwayFouls *int `json:"awayFouls"` 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 } // Clamp fouls 0..5 clamp := func(v int) int { if v < 0 { return 0 }; if v > 5 { return 5 }; return v } if payload.HomeFouls != nil { s.HomeFouls = clamp(*payload.HomeFouls) } if payload.AwayFouls != nil { s.AwayFouls = clamp(*payload.AwayFouls) } 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_.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) }