package controllers import ( "encoding/json" "fmt" "io" "net/http" neturl "net/url" "os" "path/filepath" "regexp" "strings" "sync" "time" "fotbal-club/internal/config" "fotbal-club/internal/models" "fotbal-club/internal/services" "github.com/gin-gonic/gin" "gorm.io/gorm" ) // club data cache (JSON bytes) with TTL and disk persistence type cachedItem struct { Data []byte `json:"data"` StoredAt time.Time `json:"stored_at"` } var ( clubCacheMu sync.RWMutex clubCache = map[string]cachedItem{} cacheTTL = 30 * time.Minute ) // facrRetryConfig defines retry behavior for FACR API calls var facrRetryConfig = struct { MaxAttempts int BaseDelay time.Duration MaxDelay time.Duration }{ MaxAttempts: 10, BaseDelay: 5 * time.Second, MaxDelay: 60 * time.Second, } // fetchWithRetry performs HTTP GET with retry logic and exponential backoff func fetchWithRetry(url string, timeout time.Duration) (*http.Response, error) { var lastErr error client := &http.Client{Timeout: timeout} for attempt := 1; attempt <= facrRetryConfig.MaxAttempts; attempt++ { resp, err := client.Get(url) if err != nil { lastErr = err if attempt < facrRetryConfig.MaxAttempts { multiplier := 1 << uint(attempt-1) // 2^(attempt-1) delay := facrRetryConfig.BaseDelay * time.Duration(multiplier) if delay > facrRetryConfig.MaxDelay { delay = facrRetryConfig.MaxDelay } time.Sleep(delay) } continue } // Check if response indicates an error from the FACR API if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) resp.Body.Close() // Check if body contains scraping error (temporary failure) bodyStr := string(body) if strings.Contains(bodyStr, "scraping failed") || strings.Contains(bodyStr, "Cloudflare") || strings.Contains(bodyStr, "failed to fetch") || resp.StatusCode >= 500 { lastErr = fmt.Errorf("FACR API temporary error (attempt %d): %s", attempt, bodyStr) if attempt < facrRetryConfig.MaxAttempts { multiplier := 1 << uint(attempt-1) // 2^(attempt-1) delay := facrRetryConfig.BaseDelay * time.Duration(multiplier) if delay > facrRetryConfig.MaxDelay { delay = facrRetryConfig.MaxDelay } time.Sleep(delay) } continue } // For other errors, return immediately with a reconstructed response return resp, nil } return resp, nil } return nil, fmt.Errorf("FACR API failed after %d attempts: %v", facrRetryConfig.MaxAttempts, lastErr) } func cacheDir() string { return filepath.Join("cache", "facr") } func cachePath(key string) string { return filepath.Join(cacheDir(), key+".json") } func getCachedJSON(key string) ([]byte, bool) { // memory first clubCacheMu.RLock() item, ok := clubCache[key] clubCacheMu.RUnlock() if ok && time.Since(item.StoredAt) < cacheTTL { return item.Data, true } // disk fallback b, err := os.ReadFile(cachePath(key)) if err == nil { var disk cachedItem if json.Unmarshal(b, &disk) == nil { if time.Since(disk.StoredAt) < cacheTTL { // warm memory clubCacheMu.Lock() clubCache[key] = disk clubCacheMu.Unlock() return disk.Data, true } } } return nil, false } func setCachedJSON(key string, data []byte) { item := cachedItem{Data: data, StoredAt: time.Now()} clubCacheMu.Lock() clubCache[key] = item clubCacheMu.Unlock() // persist to disk (best-effort) _ = os.MkdirAll(cacheDir(), 0o755) if b, err := json.Marshal(item); err == nil { _ = os.WriteFile(cachePath(key), b, 0o644) } } func isManualClubDataMode() bool { if config.AppConfig == nil { return false } mode := strings.ToLower(strings.TrimSpace(config.AppConfig.ClubDataMode)) return mode == "manual" } // ----- Types (mirroring facr-scraper) ----- type Competition struct { ID string `json:"id"` Code string `json:"code"` Name string `json:"name"` TeamCount string `json:"team_count"` MatchesLink string `json:"matches_link"` Matches []Match `json:"matches,omitempty"` Table *CompetitionTable `json:"table,omitempty"` } type CompetitionTable struct { Overall []TableRow `json:"overall"` } type ClubInfo struct { Name string `json:"name"` ClubID string `json:"club_id"` ClubType string `json:"club_type"` ClubInternalID string `json:"club_internal_id,omitempty"` URL string `json:"url,omitempty"` LogoURL string `json:"logo_url,omitempty"` Address string `json:"address,omitempty"` Category string `json:"category,omitempty"` Competitions []Competition `json:"competitions"` } type SearchResult struct { Name string `json:"name"` ClubID string `json:"club_id"` ClubType string `json:"club_type"` URL string `json:"url"` LogoURL string `json:"logo_url"` Category string `json:"category,omitempty"` Address string `json:"address,omitempty"` } type Match struct { DateTime string `json:"date_time"` Home string `json:"home"` HomeID string `json:"home_id,omitempty"` HomeLogoURL string `json:"home_logo_url,omitempty"` Away string `json:"away"` AwayID string `json:"away_id,omitempty"` AwayLogoURL string `json:"away_logo_url,omitempty"` Score string `json:"score"` Venue string `json:"venue"` Note string `json:"note,omitempty"` MatchID string `json:"match_id"` ReportURL string `json:"report_url,omitempty"` DelegationURL string `json:"delegation_url,omitempty"` } type TableRow struct { Rank string `json:"rank"` Team string `json:"team"` TeamID string `json:"team_id,omitempty"` TeamLogoURL string `json:"team_logo_url,omitempty"` Played string `json:"played"` Wins string `json:"wins"` Draws string `json:"draws"` Losses string `json:"losses"` Score string `json:"score"` Points string `json:"points"` } // ----- Helpers ----- func extractUUIDFromHref(href string) string { href = strings.TrimSpace(href) if href == "" { return "" } re := regexp.MustCompile(`[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}`) if m := re.FindString(href); m != "" { return m } parts := strings.Split(href, "/") if len(parts) > 0 { cand := parts[len(parts)-1] if re.MatchString(cand) { return cand } } return "" } func resolveISURL(href string) string { href = strings.TrimSpace(href) if strings.HasPrefix(href, "http://") || strings.HasPrefix(href, "https://") { if u, err := neturl.Parse(href); err == nil { u.Scheme = "https" u.Host = "is.fotbal.cz" if !strings.HasPrefix(u.Path, "/public/") { if strings.HasPrefix(u.Path, "/zapasy/") { u.Path = "/public" + u.Path } } q := u.Query() q.Del("discipline") u.RawQuery = q.Encode() return u.String() } return href } href = strings.TrimPrefix(href, "./") for strings.HasPrefix(href, "../") { href = strings.TrimPrefix(href, "../") } href = strings.TrimPrefix(href, "/") path := "/public/" + href u := neturl.URL{Scheme: "https", Host: "is.fotbal.cz", Path: path} return u.String() } // ----- Controller ----- type FACRController struct{ DB *gorm.DB } func NewFACRController(db *gorm.DB) *FACRController { return &FACRController{DB: db} } // GET /api/v1/facr/club/search?q= func (fc *FACRController) SearchClubs(c *gin.Context) { q := strings.TrimSpace(c.Query("q")) if q == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "query parameter 'q' is required"}) return } // In manual club data mode we do not perform any external FACR/fotbal.cz lookups. if isManualClubDataMode() { c.JSON(http.StatusOK, gin.H{ "query": q, "count": 0, "results": []SearchResult{}, }) return } // Use external FACR API proxy instead of scraping www.fotbal.cz directly vals := neturl.Values{} vals.Set("q", q) searchURL := "https://facr.tdvorak.dev/club/search?" + vals.Encode() resp, err := fetchWithRetry(searchURL, 10*time.Minute) if err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("Error fetching from FACR API: %v", err)}) return } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) c.Data(resp.StatusCode, "application/json", body) return } // Read and parse the external API response body, err := io.ReadAll(resp.Body) if err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("Error reading response: %v", err)}) return } // Parse the JSON to apply local processing (logo handling, filtering, deduplication) var apiResp struct { Query string `json:"query"` Count int `json:"count"` Results []SearchResult `json:"results"` } if err := json.Unmarshal(body, &apiResp); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Error parsing response: %v", err)}) return } results := apiResp.Results // Process logos through local rembg service for i := range results { if logoURL := strings.TrimSpace(results[i].LogoURL); logoURL != "" { if p, err := services.ProcessFACRLogo(logoURL); err == nil && strings.TrimSpace(p) != "" { results[i].LogoURL = p } } } // If setup is completed and a sport is configured, filter results to that sport only if fc.DB != nil { var s models.Settings if err := fc.DB.First(&s).Error; err == nil { if strings.TrimSpace(s.ClubID) != "" && strings.TrimSpace(s.ClubType) != "" { filtered := make([]SearchResult, 0, len(results)) want := strings.ToLower(strings.TrimSpace(s.ClubType)) for _, r := range results { if strings.ToLower(strings.TrimSpace(r.ClubType)) == want { filtered = append(filtered, r) } } results = filtered } } } // Deduplicate results if len(results) > 1 { unique := make([]SearchResult, 0, len(results)) seenByID := map[string]int{} seenByName := map[string]int{} normKey := func(s string) string { x := strings.ToLower(strings.TrimSpace(s)) x = strings.ReplaceAll(x, ".", "") x = strings.ReplaceAll(x, " ", "") return x } for _, r := range results { id := strings.TrimSpace(r.ClubID) if id != "" { if idx, ok := seenByID[id]; ok { // Keep the one with a logo if strings.TrimSpace(unique[idx].LogoURL) == "" && strings.TrimSpace(r.LogoURL) != "" { unique[idx] = r } continue } seenByID[id] = len(unique) unique = append(unique, r) continue } key := normKey(r.Name) if key == "" { unique = append(unique, r) continue } if idx, ok := seenByName[key]; ok { if strings.TrimSpace(unique[idx].LogoURL) == "" && strings.TrimSpace(r.LogoURL) != "" { unique[idx] = r } continue } seenByName[key] = len(unique) unique = append(unique, r) } results = unique } c.JSON(http.StatusOK, gin.H{ "query": q, "count": len(results), "results": results, }) } // GET /api/v1/facr/club/:type/:id func (fc *FACRController) GetClubInfo(c *gin.Context) { clubID := c.Param("id") clubType := c.Param("type") if clubID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "Club ID is required"}) return } cacheKey := fmt.Sprintf("%s_%s_info", clubType, clubID) force := c.Query("force") == "1" if !force { if data, ok := getCachedJSON(cacheKey); ok { c.Data(http.StatusOK, "application/json", data) return } } // Manual mode: build FACR-like payload from DB-backed manual models, no external HTTP. if isManualClubDataMode() { payload, err := fc.buildManualClubPayload(clubID, clubType) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("manual payload error: %v", err)}) return } b, err := json.Marshal(payload) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("marshal error: %v", err)}) return } setCachedJSON(cacheKey, b) c.Data(http.StatusOK, "application/json", b) return } external := fmt.Sprintf("https://facr.tdvorak.dev/club/%s/%s", clubType, clubID) resp, err := fetchWithRetry(external, 10*time.Minute) if err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("proxy error: %v", err)}) return } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) c.Data(resp.StatusCode, "application/json", body) return } b, err := io.ReadAll(resp.Body) if err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("read error: %v", err)}) return } // Best-effort: rewrite FACR logos in matches to processed local PNGs via rembg // so that all consumers receive transparent logos consistently. { var orig map[string]any if json.Unmarshal(b, &orig) == nil { if comps, ok := orig["competitions"].([]any); ok { seen := map[string]string{} for i := range comps { comp, _ := comps[i].(map[string]any) if comp == nil { continue } if matches, ok2 := comp["matches"].([]any); ok2 { for j := range matches { m, _ := matches[j].(map[string]any) if m == nil { continue } // home_logo_url if s, ok3 := m["home_logo_url"].(string); ok3 && strings.TrimSpace(s) != "" { if rep, ok := seen[s]; ok { if rep != "" && rep != s { m["home_logo_url"] = rep } } else { if p, err := services.ProcessFACRLogo(s); err == nil && strings.TrimSpace(p) != "" && p != s { seen[s] = p m["home_logo_url"] = p } else { seen[s] = s } } } // away_logo_url if s, ok3 := m["away_logo_url"].(string); ok3 && strings.TrimSpace(s) != "" { if rep, ok := seen[s]; ok { if rep != "" && rep != s { m["away_logo_url"] = rep } } else { if p, err := services.ProcessFACRLogo(s); err == nil && strings.TrimSpace(p) != "" && p != s { seen[s] = p m["away_logo_url"] = p } else { seen[s] = s } } } } } } if nb, err := json.Marshal(orig); err == nil { b = nb } } } } setCachedJSON(cacheKey, b) c.Data(http.StatusOK, "application/json", b) } // buildManualClubPayload constructs a FACR-like ClubInfo payload from manual DB models. // It is used in manual club data mode to avoid any external FACR/fotbal.cz HTTP calls. func (fc *FACRController) buildManualClubPayload(clubID, clubType string) (*ClubInfo, error) { if fc.DB == nil { return nil, fmt.Errorf("database handle not available") } clubID = strings.TrimSpace(clubID) clubType = strings.TrimSpace(clubType) if clubID == "" || clubType == "" { return nil, fmt.Errorf("missing club id or type") } // Load primary settings for club metadata when available. var s models.Settings _ = fc.DB.First(&s).Error name := strings.TrimSpace(s.ClubName) url := strings.TrimSpace(s.ClubURL) logoURL := strings.TrimSpace(s.ClubLogoURL) if name == "" { name = "" } payload := &ClubInfo{ Name: name, ClubID: clubID, ClubType: clubType, URL: url, LogoURL: logoURL, Competitions: []Competition{}, } // Load manual competitions for this club. var comps []models.ManualCompetition if err := fc.DB.Where("club_id = ? AND club_type = ?", clubID, clubType).Order("id ASC").Find(&comps).Error; err != nil { return nil, err } if len(comps) == 0 { return payload, nil } payload.Competitions = make([]Competition, len(comps)) idxByCompID := make(map[uint]int, len(comps)) compIDs := make([]uint, len(comps)) for i, c := range comps { payload.Competitions[i] = Competition{ ID: strings.TrimSpace(c.ExternalID), Code: strings.TrimSpace(c.Code), Name: strings.TrimSpace(c.Name), TeamCount: strings.TrimSpace(c.TeamCount), MatchesLink: strings.TrimSpace(c.MatchesLink), } idxByCompID[c.ID] = i compIDs[i] = c.ID } // Load manual matches for all competitions. var mm []models.ManualMatch if err := fc.DB.Where("competition_id IN ?", compIDs).Order("kickoff ASC, id ASC").Find(&mm).Error; err == nil { primaryName := name primaryID := clubID for _, m := range mm { idx, ok := idxByCompID[m.CompetitionID] if !ok { continue } // Derive home/away teams relative to primary club. hName := strings.TrimSpace(m.OpponentName) aName := primaryName hID := strings.TrimSpace(m.OpponentExternalID) aID := primaryID if m.IsHome { // Primary club at home. hName = primaryName aName = strings.TrimSpace(m.OpponentName) hID = primaryID aID = strings.TrimSpace(m.OpponentExternalID) } // Format kickoff as FACR-style "dd.MM.yyyy HH:mm". var dt string if !m.Kickoff.IsZero() { dt = m.Kickoff.In(time.Local).Format("02.01.2006 15:04") } // Compose score with optional halftime in parentheses. score := strings.TrimSpace(m.Score) if ht := strings.TrimSpace(m.HalftimeScore); ht != "" { if score != "" { score = fmt.Sprintf("%s (%s)", score, ht) } else { score = ht } } // Build FACR-like placeholder logos based on team IDs; LogoAPI/local overrides are applied on the frontend. hLogo := facrPlaceholderLogo(hID) aLogo := facrPlaceholderLogo(aID) payload.Competitions[idx].Matches = append(payload.Competitions[idx].Matches, Match{ DateTime: dt, Home: hName, HomeID: hID, HomeLogoURL: hLogo, Away: aName, AwayID: aID, AwayLogoURL: aLogo, Score: score, Venue: strings.TrimSpace(m.Venue), Note: strings.TrimSpace(m.Note), MatchID: strings.TrimSpace(m.ExternalMatchID), ReportURL: strings.TrimSpace(m.MatchURL), }) } } // Load manual table rows for all competitions. var rows []models.ManualTableRow if err := fc.DB.Where("competition_id IN ?", compIDs).Order("rank ASC, id ASC").Find(&rows).Error; err == nil { for _, r := range rows { idx, ok := idxByCompID[r.CompetitionID] if !ok { continue } tr := TableRow{ Rank: strings.TrimSpace(r.Rank), Team: strings.TrimSpace(r.TeamName), TeamID: strings.TrimSpace(r.ExternalTeamID), TeamLogoURL: facrPlaceholderLogo(strings.TrimSpace(r.ExternalTeamID)), Played: strings.TrimSpace(r.Played), Wins: strings.TrimSpace(r.Wins), Draws: strings.TrimSpace(r.Draws), Losses: strings.TrimSpace(r.Losses), Score: strings.TrimSpace(r.Score), Points: strings.TrimSpace(r.Points), } if payload.Competitions[idx].Table == nil { payload.Competitions[idx].Table = &CompetitionTable{Overall: []TableRow{}} } payload.Competitions[idx].Table.Overall = append(payload.Competitions[idx].Table.Overall, tr) } } return payload, nil } // facrPlaceholderLogo builds a static FACR-style logo URL from a team UUID. // It does not perform any HTTP requests and is safe in manual mode; LogoAPI/local // overrides remain responsible for primary logo resolution on the frontend. func facrPlaceholderLogo(teamID string) string { id := strings.TrimSpace(teamID) if id == "" { return "" } return fmt.Sprintf("https://is1.fotbal.cz/media/kluby/%s/%s_crop.jpg", id, id) } // GET /api/v1/facr/club/:type/:id/table func (fc *FACRController) GetClubTables(c *gin.Context) { clubID := c.Param("id") clubType := c.Param("type") if clubID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "Club ID is required"}) return } cacheKey := fmt.Sprintf("%s_%s_table", clubType, clubID) force := c.Query("force") == "1" if !force { if data, ok := getCachedJSON(cacheKey); ok { c.Data(http.StatusOK, "application/json", data) return } } // Manual mode: reuse manual FACR-like payload and return competitions with tables. if isManualClubDataMode() { payload, err := fc.buildManualClubPayload(clubID, clubType) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("manual payload error: %v", err)}) return } b, err := json.Marshal(payload) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("marshal error: %v", err)}) return } setCachedJSON(cacheKey, b) c.Data(http.StatusOK, "application/json", b) return } external := fmt.Sprintf("https://facr.tdvorak.dev/club/%s/%s/table", clubType, clubID) resp, err := fetchWithRetry(external, 10*time.Minute) if err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("proxy error: %v", err)}) return } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) c.Data(resp.StatusCode, "application/json", body) return } b, err := io.ReadAll(resp.Body) if err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("read error: %v", err)}) return } // Best-effort: rewrite team_logo_url in tables to processed local PNGs via rembg { var orig map[string]any if json.Unmarshal(b, &orig) == nil { if comps, ok := orig["competitions"].([]any); ok { seen := map[string]string{} for i := range comps { comp, _ := comps[i].(map[string]any) if comp == nil { continue } tbl, _ := comp["table"].(map[string]any) if tbl == nil { continue } overall, _ := tbl["overall"].([]any) for j := range overall { row, _ := overall[j].(map[string]any) if row == nil { continue } if s, ok3 := row["team_logo_url"].(string); ok3 && strings.TrimSpace(s) != "" { if rep, ok := seen[s]; ok { if rep != "" && rep != s { row["team_logo_url"] = rep } } else { if p, err := services.ProcessFACRLogo(s); err == nil && strings.TrimSpace(p) != "" && p != s { seen[s] = p row["team_logo_url"] = p } else { seen[s] = s } } } } } if nb, err := json.Marshal(orig); err == nil { b = nb } } } } setCachedJSON(cacheKey, b) c.Data(http.StatusOK, "application/json", b) }