Files
MyClub/internal/controllers/facr_controller.go
T
2026-03-20 15:19:22 +01:00

705 lines
20 KiB
Go

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
)
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()
httpClient := &http.Client{Timeout: 60 * time.Second}
resp, err := httpClient.Get(searchURL)
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)
httpClient := &http.Client{Timeout: 60 * time.Second}
resp, err := httpClient.Get(external)
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)
httpClient := &http.Client{Timeout: 60 * time.Second}
resp, err := httpClient.Get(external)
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)
}