package controllers import ( "encoding/json" "net/http" "os" "path/filepath" "strconv" "strings" "time" "github.com/gin-gonic/gin" "fotbal-club/internal/services" ) // PrefetchController exposes admin endpoints to inspect and trigger background prefetch cycles. type PrefetchController struct{} // isDuringMatchFromCache checks cached FACR JSONs to determine if a match is ongoing. func isDuringMatchFromCache(cacheDir string) bool { now := time.Now() parseDT := func(s string) (time.Time, bool) { s = strings.TrimSpace(s) if s == "" { return time.Time{}, false } layouts := []string{"02.01.2006 15:04", time.RFC3339, "2006-01-02 15:04"} for _, layout := range layouts { if t, err := time.ParseInLocation(layout, s, time.Local); err == nil { return t, true } } return time.Time{}, false } within := func(ts time.Time) bool { start := ts.Add(-15 * time.Minute) end := ts.Add(150 * time.Minute) return now.After(start) && now.Before(end) } type flatMatch struct { DateTime string `json:"date_time"` Date string `json:"date"` Time string `json:"time"` Kickoff string `json:"kickoff"` } check := func(b []byte) bool { if len(b) == 0 { return false } var payload struct { Competitions []struct { Matches []struct { DateTime string `json:"date_time"` Date string `json:"date"` Time string `json:"time"` Kickoff string `json:"kickoff"` } `json:"matches"` } `json:"competitions"` Matches []flatMatch `json:"matches"` } _ = json.Unmarshal(b, &payload) for _, c := range payload.Competitions { for _, m := range c.Matches { var ts time.Time; var ok bool if m.DateTime != "" { ts, ok = parseDT(m.DateTime) } else if m.Date != "" || m.Time != "" { ts, ok = parseDT(strings.TrimSpace(m.Date+" "+m.Time)) } else if m.Kickoff != "" { ts, ok = parseDT(m.Kickoff) } if ok && within(ts) { return true } } } for _, m := range payload.Matches { var ts time.Time; var ok bool if m.DateTime != "" { ts, ok = parseDT(m.DateTime) } else if m.Date != "" || m.Time != "" { ts, ok = parseDT(strings.TrimSpace(m.Date+" "+m.Time)) } else if m.Kickoff != "" { ts, ok = parseDT(m.Kickoff) } if ok && within(ts) { return true } } return false } files := []string{ filepath.Join(cacheDir, "facr_club_info.json"), filepath.Join(cacheDir, "facr_tables.json") } for _, p := range files { if b, err := os.ReadFile(p); err == nil { if check(b) { return true } } } return false } func NewPrefetchController() *PrefetchController { return &PrefetchController{} } // Status returns info about the last fetch and the approximate next run time. // GET /api/v1/admin/prefetch/status func (pc *PrefetchController) Status(c *gin.Context) { if c.GetString("userRole") != "admin" { c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) return } cacheDir := filepath.Join("cache", "prefetch") metaPath := filepath.Join(cacheDir, "meta.json") var lastUpdated string if b, err := os.ReadFile(metaPath); err == nil { var m struct{ LastUpdated string `json:"lastUpdated"` } if json.Unmarshal(b, &m) == nil { lastUpdated = m.LastUpdated } } // Determine configured interval interval := 30 * time.Minute if v := strings.TrimSpace(os.Getenv("PREFETCH_INTERVAL_MINUTES")); v != "" { if mins, err := strconv.Atoi(v); err == nil && mins > 0 { interval = time.Duration(mins) * time.Minute } } // Fast mode if during match fast := isDuringMatchFromCache(cacheDir) next := time.Now().Add(interval) if fast { next = time.Now().Add(5 * time.Minute) } c.JSON(http.StatusOK, gin.H{ "lastUpdated": lastUpdated, "intervalMinutes": int(interval / time.Minute), "fastMode": fast, "nextApproximate": next.Format(time.RFC3339), }) } // Trigger starts an immediate prefetch cycle (best effort) // POST /api/v1/admin/prefetch/trigger func (pc *PrefetchController) Trigger(c *gin.Context) { if c.GetString("userRole") != "admin" { c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) return } // Resolve the base URL similar to main.go logic base := os.Getenv("PREFETCH_TARGET") if strings.TrimSpace(base) == "" { port := strings.TrimSpace(os.Getenv("PORT")) if port == "" { port = "8080" } base = "http://127.0.0.1:" + port + "/api/v1" } go func() { // fire-and-forget services.PrefetchOnce(base) // Additionally trigger an immediate Zonerama refresh based on cached settings // This ensures the admin trigger updates the gallery right away. cacheDir := filepath.Join("cache", "prefetch") b, err := os.ReadFile(filepath.Join(cacheDir, "settings.json")) if err == nil { var s struct{ ZoneramaURL string `json:"zonerama_url"` GalleryURL string `json:"gallery_url"` } if jsonErr := json.Unmarshal(b, &s); jsonErr == nil { link := strings.TrimSpace(s.ZoneramaURL) if link == "" { link = strings.TrimSpace(s.GalleryURL) } if link != "" { _ = services.RefreshZoneramaNow(link) } } } // Regenerate flat gallery files from existing albums _ = services.RegenerateFlatGalleryFiles() }() c.JSON(http.StatusOK, gin.H{"message": "Prefetch started"}) }