mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-03 18:22:57 +00:00
hot fix #1
This commit is contained in:
@@ -13,6 +13,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"fotbal-club/internal/config"
|
||||
"fotbal-club/internal/models"
|
||||
"fotbal-club/internal/services"
|
||||
|
||||
@@ -21,9 +22,6 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// In-memory cache for logo lookup
|
||||
var logoCache = map[string]string{}
|
||||
|
||||
// club data cache (JSON bytes) with TTL and disk persistence
|
||||
type cachedItem struct {
|
||||
Data []byte `json:"data"`
|
||||
@@ -81,6 +79,14 @@ func setCachedJSON(key string, data []byte) {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -150,15 +156,6 @@ type TableRow struct {
|
||||
|
||||
// ----- Helpers -----
|
||||
|
||||
func containsFold(s, substr string) bool {
|
||||
s = strings.ToLower(strings.TrimSpace(s))
|
||||
substr = strings.ToLower(strings.TrimSpace(substr))
|
||||
if substr == "" {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(s, substr)
|
||||
}
|
||||
|
||||
func extractUUIDFromHref(href string) string {
|
||||
href = strings.TrimSpace(href)
|
||||
if href == "" {
|
||||
@@ -177,145 +174,6 @@ func extractUUIDFromHref(href string) string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func getLogoBySearch(name string) string {
|
||||
key := strings.ToLower(strings.TrimSpace(name))
|
||||
if key == "" {
|
||||
return ""
|
||||
}
|
||||
if v, ok := logoCache[key]; ok {
|
||||
return v
|
||||
}
|
||||
// Query local API routed through this same server
|
||||
apiURL := fmt.Sprintf("http://localhost:8080/api/v1/facr/club/search?q=%s", neturl.QueryEscape(name))
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
resp, err := client.Get(apiURL)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
return ""
|
||||
}
|
||||
var payload struct {
|
||||
Results []struct {
|
||||
Name string `json:"name"`
|
||||
LogoURL string `json:"logo_url"`
|
||||
} `json:"results"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||
return ""
|
||||
}
|
||||
best := ""
|
||||
for _, r := range payload.Results {
|
||||
if strings.EqualFold(strings.TrimSpace(r.Name), strings.TrimSpace(name)) {
|
||||
best = r.LogoURL
|
||||
break
|
||||
}
|
||||
}
|
||||
if best == "" {
|
||||
for _, r := range payload.Results {
|
||||
if strings.Contains(strings.ToLower(r.Name), key) || strings.Contains(key, strings.ToLower(r.Name)) {
|
||||
best = r.LogoURL
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if best == "" && len(payload.Results) > 0 {
|
||||
best = payload.Results[0].LogoURL
|
||||
}
|
||||
if best != "" {
|
||||
// Attempt to process FACR logos to transparent PNG via rembg (best-effort)
|
||||
if p, err := services.ProcessFACRLogo(best); err == nil && strings.TrimSpace(p) != "" {
|
||||
best = p
|
||||
}
|
||||
logoCache[key] = best
|
||||
return best
|
||||
}
|
||||
|
||||
// Fallback: directly scrape fotbal.cz search (same logic as SearchClubs)
|
||||
vals := neturl.Values{}
|
||||
vals.Set("q", name)
|
||||
searchURL := "https://www.fotbal.cz/club/hledej?" + vals.Encode()
|
||||
req, err := http.NewRequest("GET", searchURL, nil)
|
||||
if err != nil {
|
||||
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")
|
||||
resp2, err := client.Do(req)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer resp2.Body.Close()
|
||||
if resp2.StatusCode != http.StatusOK {
|
||||
io.Copy(io.Discard, resp2.Body)
|
||||
return ""
|
||||
}
|
||||
doc, err := goquery.NewDocumentFromReader(resp2.Body)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
// choose first exact match, else first contains, else empty
|
||||
exact := ""
|
||||
partial := ""
|
||||
doc.Find("li.ListItemSplit").Each(func(_ int, li *goquery.Selection) {
|
||||
a := li.Find("a.Link--inverted").First()
|
||||
n := strings.TrimSpace(a.Find("span.H7").First().Text())
|
||||
if n == "" {
|
||||
n = strings.TrimSpace(a.Text())
|
||||
}
|
||||
img := a.Find("img").First()
|
||||
src, _ := img.Attr("src")
|
||||
if src == "" {
|
||||
return
|
||||
}
|
||||
if exact == "" && strings.EqualFold(n, name) {
|
||||
exact = src
|
||||
}
|
||||
if partial == "" && (strings.Contains(strings.ToLower(n), key) || strings.Contains(key, strings.ToLower(n))) {
|
||||
partial = src
|
||||
}
|
||||
})
|
||||
best = exact
|
||||
if best == "" {
|
||||
best = partial
|
||||
}
|
||||
if best != "" {
|
||||
if p, err := services.ProcessFACRLogo(best); err == nil && strings.TrimSpace(p) != "" {
|
||||
best = p
|
||||
}
|
||||
logoCache[key] = best
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
func getLogo(teamName, teamID string) string {
|
||||
placeholder := "/dist/img/logo-club-empty.svg"
|
||||
name := strings.ToLower(strings.TrimSpace(teamName))
|
||||
if name == "" || strings.Contains(name, "volno") || strings.Contains(name, "volný los") || strings.Contains(name, "volny los") || strings.Contains(name, "bye") {
|
||||
return placeholder
|
||||
}
|
||||
if logo := getLogoBySearch(teamName); logo != "" {
|
||||
if p, err := services.ProcessFACRLogo(logo); err == nil && strings.TrimSpace(p) != "" {
|
||||
return p
|
||||
}
|
||||
return logo
|
||||
}
|
||||
tid := strings.TrimSpace(teamID)
|
||||
if tid != "" {
|
||||
u := fmt.Sprintf("https://is1.fotbal.cz/media/kluby/%s/%s_crop.jpg", tid, tid)
|
||||
if p, err := services.ProcessFACRLogo(u); err == nil && strings.TrimSpace(p) != "" {
|
||||
return p
|
||||
}
|
||||
return u
|
||||
}
|
||||
return placeholder
|
||||
}
|
||||
|
||||
func resolveISURL(href string) string {
|
||||
href = strings.TrimSpace(href)
|
||||
if strings.HasPrefix(href, "http://") || strings.HasPrefix(href, "https://") {
|
||||
@@ -357,6 +215,15 @@ func (fc *FACRController) SearchClubs(c *gin.Context) {
|
||||
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()
|
||||
@@ -536,6 +403,23 @@ func (fc *FACRController) GetClubInfo(c *gin.Context) {
|
||||
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)
|
||||
@@ -615,6 +499,162 @@ func (fc *FACRController) GetClubInfo(c *gin.Context) {
|
||||
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")
|
||||
@@ -631,6 +671,23 @@ func (fc *FACRController) GetClubTables(c *gin.Context) {
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user