mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-03 18:22:57 +00:00
755 lines
22 KiB
Go
755 lines
22 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/PuerkitoBio/goquery"
|
|
"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
|
|
}
|
|
vals := neturl.Values{}
|
|
vals.Set("q", q)
|
|
searchURL := "https://www.fotbal.cz/club/hledej?" + vals.Encode()
|
|
|
|
req, err := http.NewRequest("GET", searchURL, nil)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Error creating request: %v", err)})
|
|
return
|
|
}
|
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36")
|
|
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8")
|
|
req.Header.Set("Accept-Language", "cs-CZ,cs;q=0.9,en;q=0.8")
|
|
req.Header.Set("Referer", "https://www.fotbal.cz/club/hledej")
|
|
client := &http.Client{}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Error fetching search page: %v", err)})
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
// Try once more with quoted query if short tokens present
|
|
resp.Body.Close()
|
|
searchURL2 := searchURL
|
|
tokens := strings.Fields(q)
|
|
for _, t := range tokens {
|
|
if len([]rune(t)) <= 2 {
|
|
vals2 := neturl.Values{}
|
|
vals2.Set("q", "\""+q+"\"")
|
|
searchURL2 = "https://www.fotbal.cz/club/hledej?" + vals2.Encode()
|
|
break
|
|
}
|
|
}
|
|
req2, _ := http.NewRequest("GET", searchURL2, nil)
|
|
req2.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36")
|
|
req2.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
|
|
req2.Header.Set("Accept-Language", "en-US,en;q=0.9")
|
|
resp2, err2 := client.Do(req2)
|
|
if err2 != nil {
|
|
c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("Error fetching (retry): %v", err2)})
|
|
return
|
|
}
|
|
defer resp2.Body.Close()
|
|
if resp2.StatusCode != http.StatusOK {
|
|
c.JSON(http.StatusOK, gin.H{"query": q, "count": 0, "results": []SearchResult{}})
|
|
return
|
|
}
|
|
resp = resp2
|
|
}
|
|
|
|
doc, err := goquery.NewDocumentFromReader(resp.Body)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Error parsing HTML: %v", err)})
|
|
return
|
|
}
|
|
var results []SearchResult
|
|
doc.Find("li.ListItemSplit").Each(func(_ int, li *goquery.Selection) {
|
|
a := li.Find("a.Link--inverted").First()
|
|
href, _ := a.Attr("href")
|
|
if href == "" {
|
|
return
|
|
}
|
|
name := strings.TrimSpace(a.Find("span.H7").First().Text())
|
|
if name == "" {
|
|
name = strings.TrimSpace(a.Text())
|
|
}
|
|
img := a.Find("img").First()
|
|
logoURL, _ := img.Attr("src")
|
|
// Best-effort: Process FACR logos to transparent PNG. Non-facr URLs are returned unchanged.
|
|
if p, err := services.ProcessFACRLogo(logoURL); err == nil && strings.TrimSpace(p) != "" {
|
|
logoURL = p
|
|
}
|
|
category := strings.TrimSpace(li.Find(".ClubCategories .BadgeCategory").First().Text())
|
|
address := strings.TrimSpace(li.Find(".ClubAddress p").First().Text())
|
|
clubType := "football"
|
|
if strings.Contains(strings.ToLower(href), "/futsal/") {
|
|
clubType = "futsal"
|
|
}
|
|
parts := strings.Split(strings.TrimRight(href, "/"), "/")
|
|
clubID := ""
|
|
if len(parts) > 0 {
|
|
clubID = parts[len(parts)-1]
|
|
}
|
|
if !strings.HasPrefix(href, "http://") && !strings.HasPrefix(href, "https://") {
|
|
href = "https://www.fotbal.cz" + href
|
|
}
|
|
results = append(results, SearchResult{
|
|
Name: name,
|
|
ClubID: clubID,
|
|
ClubType: clubType,
|
|
URL: href,
|
|
LogoURL: logoURL,
|
|
Category: category,
|
|
Address: address,
|
|
})
|
|
})
|
|
|
|
// 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
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// respond and close the function
|
|
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: 15 * 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: 15 * 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)
|
|
}
|