This commit is contained in:
Tomas Dvorak
2025-11-21 08:44:44 +01:00
parent c941313fd5
commit f5b6f83974
108 changed files with 8642 additions and 5871 deletions
+231
View File
@@ -0,0 +1,231 @@
package services
import (
"context"
"crypto/sha1"
"encoding/hex"
"errors"
"fmt"
"image"
_ "image/gif"
_ "image/jpeg"
"image/png"
"io"
"net/http"
neturl "net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"fotbal-club/internal/config"
)
// saveAsPNG decodes an image from srcPath and writes it as PNG to dstPath.
func saveAsPNG(srcPath, dstPath string) error {
f, err := os.Open(srcPath)
if err != nil {
return err
}
defer f.Close()
img, _, err := image.Decode(f)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(dstPath), 0o755); err != nil {
return err
}
tmp := dstPath + ".tmp"
out, err := os.Create(tmp)
if err != nil {
return err
}
// Use png encoder (imported via blank import)
if err := pngEncode(out, img); err != nil {
_ = out.Close()
_ = os.Remove(tmp)
return err
}
if err := out.Close(); err != nil {
_ = os.Remove(tmp)
return err
}
return os.Rename(tmp, dstPath)
}
// pngEncode wraps png.Encode without importing directly to top to keep imports tidy
func pngEncode(w io.Writer, img image.Image) error { return png.Encode(w, img) }
// ProcessFACRLogo downloads a logo from fotbal.cz (FACR) and removes background via rembg.
// Returns a public URL (under /uploads) to a transparent PNG. If processing fails or the
// URL is not a FACR source, returns the original URL.
func ProcessFACRLogo(src string) (string, error) {
u := strings.TrimSpace(src)
if u == "" {
return "", fmt.Errorf("empty url")
}
// Feature flag: allow disabling background removal entirely via .env
if config.AppConfig != nil && !config.AppConfig.RembgEnabled {
// Simply return the original URL (no processing)
return u, nil
}
// Unwrap proxied URLs like /api/v1/proxy/image?url=...
if strings.Contains(u, "/proxy/image") {
if parsed, err := neturl.Parse(u); err == nil {
raw := parsed.Query().Get("url")
if strings.TrimSpace(raw) != "" {
u = raw
}
}
}
if !isFACRURL(u) {
// Only process fotbal.cz sources; leave others (logoapi etc.) unchanged
return u, nil
}
baseUpload := strings.TrimSpace(config.AppConfig.UploadDir)
if baseUpload == "" {
baseUpload = "./uploads"
}
key := facrKeyFromURL(u)
outPath := filepath.Join(baseUpload, "logos", "facr", key+".png")
if info, err := os.Stat(outPath); err == nil && info.Size() > 0 {
return toPublicURL(outPath), nil
}
// Ensure directory
if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil {
return u, err
}
// Download source to temp file
inTmp := outPath + ".in"
if err := downloadWithUA(u, inTmp); err != nil {
// On download failure, fallback to original URL
return u, err
}
defer os.Remove(inTmp)
// Run Python rembg script with timeout
script := filepath.Join("scripts", "rembg_remove_bg.py")
outTmp := outPath + ".tmp"
ctx, cancel := context.WithTimeout(context.Background(), 40*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "python3", script, inTmp, outTmp)
// Inherit minimal env; ensure PATH is present. If python3 not installed, this will fail.
if err := cmd.Run(); err != nil {
// If script missing or python not installed, simply return original FACR URL (fallback)
_ = os.Remove(outTmp)
return u, err
}
// Move tmp to destination if non-empty
if fi, err := os.Stat(outTmp); err == nil && fi.Size() > 0 {
if err := os.Rename(outTmp, outPath); err == nil {
return toPublicURL(outPath), nil
}
}
_ = os.Remove(outTmp)
return u, errors.New("rembg produced no output")
}
func isFACRURL(s string) bool {
pu, err := neturl.Parse(s)
if err != nil {
return strings.Contains(strings.ToLower(s), "fotbal.cz")
}
h := strings.ToLower(pu.Host)
return strings.Contains(h, "fotbal.cz")
}
// facrKeyFromURL tries to extract a stable key (team UUID) from known FACR paths; falls back to sha1(url).
func facrKeyFromURL(s string) string {
// Try to parse URL path and find /media/kluby/<id>/
if u, err := neturl.Parse(s); err == nil {
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
for i := 0; i < len(parts)-1; i++ {
if parts[i] == "kluby" && i+1 < len(parts) {
cand := strings.TrimSpace(parts[i+1])
if cand != "" {
return sanitizeFileKey(cand)
}
}
}
// Some search images may embed id in filename like <id>_crop.jpg
base := filepath.Base(u.Path)
if idx := strings.Index(strings.ToLower(base), "_crop"); idx > 0 {
return sanitizeFileKey(base[:idx])
}
}
// Fallback: sha1 of URL
h := sha1.Sum([]byte(s))
return hex.EncodeToString(h[:])
}
func sanitizeFileKey(s string) string {
s = strings.ToLower(s)
// Keep alphanum and dashes only
cleaned := make([]rune, 0, len(s))
for _, r := range s {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' {
cleaned = append(cleaned, r)
}
}
if len(cleaned) == 0 {
return "unknown"
}
return string(cleaned)
}
func toPublicURL(path string) string {
// Assumes files live under ./uploads
// Convert absolute/relative FS path to /uploads/...
i := strings.Index(path, string(filepath.Separator)+"uploads"+string(filepath.Separator))
if i >= 0 {
rel := path[i+1:]
return "/" + filepath.ToSlash(rel)
}
// Try to find trailing uploads/... even if base dir differs
idx := strings.Index(strings.ReplaceAll(path, "\\", "/"), "/uploads/")
if idx >= 0 {
return path[idx:]
}
// Fallback: cannot compute public URL
return path
}
func downloadWithUA(src, outPath string) error {
client := &http.Client{Timeout: 20 * time.Second}
req, err := http.NewRequest("GET", src, nil)
if err != nil {
return err
}
req.Header.Set("User-Agent", "fotbal-club/1.0")
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("status %d", resp.StatusCode)
}
f, err := os.Create(outPath)
if err != nil {
return err
}
if _, err := io.Copy(f, resp.Body); err != nil {
_ = f.Close()
_ = os.Remove(outPath)
return err
}
if err := f.Close(); err != nil {
_ = os.Remove(outPath)
return err
}
return nil
}
+181
View File
@@ -0,0 +1,181 @@
package services
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
"fotbal-club/internal/config"
)
// EnsureFACRLogosProcessed triggers post-processing of FACR logos referenced
// in cached prefetch JSON files (facr_club_info.json, facr_tables.json).
// It is idempotent and safe to call multiple times.
func EnsureFACRLogosProcessed(cacheDir string) error {
if config.AppConfig != nil && !config.AppConfig.RembgEnabled {
return nil
}
var firstErr error
clubInfo := filepath.Join(cacheDir, "facr_club_info.json")
if _, err := os.Stat(clubInfo); err == nil {
if err := postProcessFACRClubInfoLogos(cacheDir); err != nil && firstErr == nil {
firstErr = fmt.Errorf("club_info: %w", err)
}
}
tables := filepath.Join(cacheDir, "facr_tables.json")
if _, err := os.Stat(tables); err == nil {
if err := postProcessFACRTablesLogos(cacheDir); err != nil && firstErr == nil {
firstErr = fmt.Errorf("tables: %w", err)
}
}
return firstErr
}
// progress state for rembg batch
var rembgMu sync.RWMutex
var rembgRunning bool
var rembgTotal int
var rembgDone int
var rembgStarted time.Time
var rembgFinished *time.Time
// RembgStatus represents current background removal progress
type RembgStatus struct {
Running bool `json:"running"`
Total int `json:"total"`
Done int `json:"done"`
StartedAt time.Time `json:"started_at"`
FinishedAt *time.Time `json:"finished_at,omitempty"`
}
// GetRembgStatus returns a snapshot of current rembg batch progress
func GetRembgStatus() RembgStatus {
rembgMu.RLock()
defer rembgMu.RUnlock()
return RembgStatus{
Running: rembgRunning,
Total: rembgTotal,
Done: rembgDone,
StartedAt: rembgStarted,
FinishedAt: rembgFinished,
}
}
// StartFACRLogosBatch scans cached FACR JSONs and processes all referenced FACR logos
// using ProcessFACRLogo. Progress is tracked via GetRembgStatus. If a batch is already
// running, this call is a no-op and returns false. Otherwise returns true and starts
// a background goroutine.
func StartFACRLogosBatch(cacheDir string) bool {
if config.AppConfig != nil && !config.AppConfig.RembgEnabled {
return false
}
rembgMu.Lock()
if rembgRunning {
rembgMu.Unlock()
return false
}
// Collect unique FACR logo URLs to determine total
urls := collectFACRLogos(cacheDir)
rembgRunning = true
rembgTotal = len(urls)
rembgDone = 0
rembgStarted = time.Now()
rembgFinished = nil
rembgMu.Unlock()
if len(urls) == 0 {
// nothing to do; mark finished quickly
rembgMu.Lock()
rembgRunning = false
now := time.Now()
rembgFinished = &now
rembgMu.Unlock()
return true
}
go func(urlList []string) {
defer func() {
// Best-effort: after processing, ensure JSONs are rewritten to point to processed URLs
_ = EnsureFACRLogosProcessed(cacheDir)
rembgMu.Lock()
rembgRunning = false
now := time.Now()
rembgFinished = &now
rembgMu.Unlock()
}()
for _, u := range urlList {
_, _ = ProcessFACRLogo(u) // best effort; skip errors
rembgMu.Lock()
rembgDone++
rembgMu.Unlock()
}
}(urls)
return true
}
// collectFACRLogos returns unique FACR-hosted logo URLs from cached prefetch JSONs.
func collectFACRLogos(cacheDir string) []string {
uniq := map[string]struct{}{}
add := func(s string) {
s = strings.TrimSpace(s)
if s == "" {
return
}
// Only FACR sources; skip already-processed local /uploads and other hosts
ls := strings.ToLower(s)
if strings.HasPrefix(ls, "/uploads/") || strings.HasPrefix(ls, "/dist/") {
return
}
if !strings.Contains(ls, "fotbal.cz") {
return
}
uniq[s] = struct{}{}
}
// facr_club_info.json
if b, err := os.ReadFile(filepath.Join(cacheDir, "facr_club_info.json")); err == nil {
var payload struct {
Competitions []struct {
Matches []struct {
HomeLogoURL string `json:"home_logo_url"`
AwayLogoURL string `json:"away_logo_url"`
} `json:"matches"`
} `json:"competitions"`
}
if json.Unmarshal(b, &payload) == nil {
for _, c := range payload.Competitions {
for _, m := range c.Matches {
add(m.HomeLogoURL)
add(m.AwayLogoURL)
}
}
}
}
// facr_tables.json
if b, err := os.ReadFile(filepath.Join(cacheDir, "facr_tables.json")); err == nil {
var payload struct {
Competitions []struct {
Table struct {
Overall []struct {
TeamLogoURL string `json:"team_logo_url"`
} `json:"overall"`
} `json:"table"`
} `json:"competitions"`
}
if json.Unmarshal(b, &payload) == nil {
for _, c := range payload.Competitions {
for _, r := range c.Table.Overall {
add(r.TeamLogoURL)
}
}
}
}
out := make([]string, 0, len(uniq))
for k := range uniq {
out = append(out, k)
}
return out
}
+385 -142
View File
@@ -14,6 +14,8 @@ import (
"strings"
"sync/atomic"
"time"
"fotbal-club/internal/config"
)
// StartPrefetcher starts a background job that periodically fetches
@@ -81,7 +83,7 @@ func fetchZonerama(link string) error {
// Increase timeout to 60s since the API can take longer to fetch
client := &http.Client{Timeout: 60 * time.Second}
// Retry logic with exponential backoff (3 attempts)
var resp *http.Response
var err error
@@ -114,7 +116,7 @@ func fetchZonerama(link string) error {
time.Sleep(backoff)
}
}
if err != nil {
return err
}
@@ -165,7 +167,7 @@ func fetchZonerama(link string) error {
}
log.Printf("[prefetch] Zonerama: Fetching %d albums with photos...", len(profile.Albums))
// Fetch individual albums with photos
photoLimit := envInt("ZONERAMA_PHOTO_LIMIT", 50) // Default 50 photos per album
fetchedAlbums, err := fetchZoneramaAlbums(profile.Albums, photoLimit, client)
@@ -223,7 +225,7 @@ func fetchZoneramaAlbums(albums []struct {
// Fetch album with photos
apiURL := fmt.Sprintf("https://zonerama.tdvorak.dev/zonerama-album?link=%s&photo_limit=%d",
url.QueryEscape(album.URL), photoLimit)
log.Printf("[prefetch] Zonerama: Fetching album %d/%d: %s", i+1, len(albums), album.URL)
req, err := http.NewRequest("GET", apiURL, nil)
@@ -238,7 +240,7 @@ func fetchZoneramaAlbums(albums []struct {
log.Printf("[prefetch] Zonerama: Failed to fetch album %s: %v", album.ID, err)
continue
}
if resp.StatusCode != 200 {
log.Printf("[prefetch] Zonerama: Album %s returned status %d", album.ID, resp.StatusCode)
_ = resp.Body.Close()
@@ -255,9 +257,9 @@ func fetchZoneramaAlbums(albums []struct {
albumData.FetchedAt = fetchedAt
result = append(result, albumData)
log.Printf("[prefetch] Zonerama: Album %s fetched with %d photos", albumData.ID, len(albumData.Photos))
// Small delay between requests to avoid overwhelming the API
if i < len(albums)-1 {
time.Sleep(500 * time.Millisecond)
@@ -506,28 +508,28 @@ func PrefetchOnce(baseURL string) {
// doPrefetchCycle performs one full prefetch cycle: static public endpoints + dynamic FACR endpoints.
func doPrefetchCycle(client *http.Client, baseURL string) {
cacheDir := filepath.Join("cache", "prefetch")
_ = os.MkdirAll(cacheDir, 0o755)
start := time.Now()
// Track endpoint fetch statuses for admin/debugging payload
type epStatus struct {
Path string `json:"path"`
File string `json:"file"`
Ok bool `json:"ok"`
Error string `json:"error,omitempty"`
}
var statuses []epStatus
cacheDir := filepath.Join("cache", "prefetch")
_ = os.MkdirAll(cacheDir, 0o755)
start := time.Now()
// Track endpoint fetch statuses for admin/debugging payload
type epStatus struct {
Path string `json:"path"`
File string `json:"file"`
Ok bool `json:"ok"`
Error string `json:"error,omitempty"`
}
var statuses []epStatus
// 1) Static public endpoints
endpoints := map[string]string{
"/settings": "settings.json",
"/seo": "seo.json",
"/articles?page=1&page_size=10&published=true": "articles.json",
"/sponsors": "sponsors.json",
"/events/upcoming": "events_upcoming.json",
"/public/team-logo-overrides": "team_logo_overrides.json",
"/competition-aliases": "competition_aliases.json",
}
// 1) Static public endpoints
endpoints := map[string]string{
"/settings": "settings.json",
"/seo": "seo.json",
"/articles?page=1&page_size=10&published=true": "articles.json",
"/sponsors": "sponsors.json",
"/events/upcoming": "events_upcoming.json",
"/public/team-logo-overrides": "team_logo_overrides.json",
"/competition-aliases": "competition_aliases.json",
}
statuses = make([]epStatus, 0, len(endpoints))
for path, file := range endpoints {
url := baseURL + path
@@ -542,82 +544,96 @@ func doPrefetchCycle(client *http.Client, baseURL string) {
}
}
// Prepare matches.json from FACR data if available; fallback to events_upcoming.json
createdMatches := false
buildMatchesFromFACR := func(data []byte) bool {
type facrMatch struct {
DateTime string `json:"date_time"`
Home string `json:"home"`
Away string `json:"away"`
Venue string `json:"venue"`
HomeLogoURL string `json:"home_logo_url"`
AwayLogoURL string `json:"away_logo_url"`
MatchID string `json:"match_id"`
}
var facr struct {
Competitions []struct {
Name string `json:"name"`
Code string `json:"code"`
Matches []facrMatch `json:"matches"`
} `json:"competitions"`
}
if err := json.Unmarshal(data, &facr); err != nil {
return false
}
// Collect upcoming matches (next 30 days) in simplified format
now := time.Now()
max := now.Add(30 * 24 * time.Hour)
var out []map[string]any
for _, c := range facr.Competitions {
compName := strings.TrimSpace(c.Name)
if compName == "" {
compName = strings.TrimSpace(c.Code)
}
for _, m := range c.Matches {
dt := strings.TrimSpace(m.DateTime)
if dt == "" {
continue
}
// dt like "12.08.2023 18:00"
parts := strings.SplitN(dt, " ", 2)
d := parts[0]
t := ""
if len(parts) > 1 { t = parts[1] }
// parse date dd.mm.yyyy
dd := strings.Split(d, ".")
if len(dd) < 3 { continue }
day := dd[0]
month := dd[1]
year := dd[2]
if len(month) == 1 { month = "0" + month }
if len(day) == 1 { day = "0" + day }
isoDate := year + "-" + month + "-" + day
if len(t) >= 5 {
t = t[:5]
} else {
t = "18:00"
}
// Build time.Time for filtering
ts, err := time.ParseInLocation("2006-01-02 15:04", isoDate+" "+t, time.Local)
if err != nil { continue }
if ts.Before(now) || ts.After(max) { continue }
out = append(out, map[string]any{
"id": m.MatchID,
"home": m.Home,
"away": m.Away,
"competition": compName,
"date": isoDate,
"time": t,
"venue": m.Venue,
"home_logo_url": m.HomeLogoURL,
"away_logo_url": m.AwayLogoURL,
})
}
}
if len(out) == 0 { return false }
_ = writeJSONAtomic(filepath.Join(cacheDir, "matches.json"), out)
return true
}
// Prepare matches.json from FACR data if available; fallback to events_upcoming.json
createdMatches := false
buildMatchesFromFACR := func(data []byte) bool {
type facrMatch struct {
DateTime string `json:"date_time"`
Home string `json:"home"`
Away string `json:"away"`
Venue string `json:"venue"`
HomeLogoURL string `json:"home_logo_url"`
AwayLogoURL string `json:"away_logo_url"`
MatchID string `json:"match_id"`
}
var facr struct {
Competitions []struct {
Name string `json:"name"`
Code string `json:"code"`
Matches []facrMatch `json:"matches"`
} `json:"competitions"`
}
if err := json.Unmarshal(data, &facr); err != nil {
return false
}
// Collect upcoming matches (next 30 days) in simplified format
now := time.Now()
max := now.Add(30 * 24 * time.Hour)
var out []map[string]any
for _, c := range facr.Competitions {
compName := strings.TrimSpace(c.Name)
if compName == "" {
compName = strings.TrimSpace(c.Code)
}
for _, m := range c.Matches {
dt := strings.TrimSpace(m.DateTime)
if dt == "" {
continue
}
// dt like "12.08.2023 18:00"
parts := strings.SplitN(dt, " ", 2)
d := parts[0]
t := ""
if len(parts) > 1 {
t = parts[1]
}
// parse date dd.mm.yyyy
dd := strings.Split(d, ".")
if len(dd) < 3 {
continue
}
day := dd[0]
month := dd[1]
year := dd[2]
if len(month) == 1 {
month = "0" + month
}
if len(day) == 1 {
day = "0" + day
}
isoDate := year + "-" + month + "-" + day
if len(t) >= 5 {
t = t[:5]
} else {
t = "18:00"
}
// Build time.Time for filtering
ts, err := time.ParseInLocation("2006-01-02 15:04", isoDate+" "+t, time.Local)
if err != nil {
continue
}
if ts.Before(now) || ts.After(max) {
continue
}
out = append(out, map[string]any{
"id": m.MatchID,
"home": m.Home,
"away": m.Away,
"competition": compName,
"date": isoDate,
"time": t,
"venue": m.Venue,
"home_logo_url": m.HomeLogoURL,
"away_logo_url": m.AwayLogoURL,
})
}
}
if len(out) == 0 {
return false
}
_ = writeJSONAtomic(filepath.Join(cacheDir, "matches.json"), out)
return true
}
// 2) Dynamic FACR endpoints based on saved settings
settingsPath := filepath.Join(cacheDir, "settings.json")
@@ -651,12 +667,31 @@ func doPrefetchCycle(client *http.Client, baseURL string) {
statuses = append(statuses, epStatus{Path: path, File: file, Ok: false, Error: err.Error()})
} else {
log.Printf("[prefetch] FACR SUCCESS: updated %s", file)
// Post-process logo URLs only when REMBG is enabled
if config.AppConfig != nil && config.AppConfig.RembgEnabled {
// Post-process club info logos to ensure transparent backgrounds on FACR logos
if file == "facr_club_info.json" {
if err := postProcessFACRClubInfoLogos(cacheDir); err != nil {
log.Printf("[prefetch] FACR post-process WARN: %v", err)
}
}
// Post-process tables logos similarly (team_logo_url)
if file == "facr_tables.json" {
if err := postProcessFACRTablesLogos(cacheDir); err != nil {
log.Printf("[prefetch] FACR tables post-process WARN: %v", err)
}
}
} else {
log.Printf("[prefetch] REMBG disabled, skipping FACR logo post-processing for %s", file)
}
statuses = append(statuses, epStatus{Path: path, File: file, Ok: true})
}
}
// Try to build matches.json from freshly fetched FACR prefetch file
if b, err := os.ReadFile(filepath.Join(cacheDir, "facr_club_info.json")); err == nil {
if buildMatchesFromFACR(b) { createdMatches = true }
if buildMatchesFromFACR(b) {
createdMatches = true
}
}
} else {
log.Printf("[prefetch] WARNING: FACR skipped: missing club_id=%q or club_type=%q in settings", clubID, clubType)
@@ -666,9 +701,13 @@ func doPrefetchCycle(client *http.Client, baseURL string) {
if !createdMatches && clubID != "" && clubType != "" {
facrCachePath := filepath.Join("cache", "facr", fmt.Sprintf("%s_%s_info.json", clubType, clubID))
if b, err := os.ReadFile(facrCachePath); err == nil {
var cached struct{ Data []byte `json:"data"` }
var cached struct {
Data []byte `json:"data"`
}
if jsonErr := json.Unmarshal(b, &cached); jsonErr == nil && len(cached.Data) > 0 {
if buildMatchesFromFACR(cached.Data) { createdMatches = true }
if buildMatchesFromFACR(cached.Data) {
createdMatches = true
}
}
}
}
@@ -700,22 +739,22 @@ var prefetchInFlight int32
var prefetchPending int32
func doPrefetchCycleGuarded(client *http.Client, baseURL string) {
if !atomic.CompareAndSwapInt32(&prefetchInFlight, 0, 1) {
// Mark a rerun so we don't lose triggers (e.g., from setup) while a cycle is running
atomic.StoreInt32(&prefetchPending, 1)
log.Printf("[prefetch] in-flight: marked pending rerun")
return
}
defer func() {
atomic.StoreInt32(&prefetchInFlight, 0)
// If a trigger arrived during this run, execute one more cycle immediately
if atomic.SwapInt32(&prefetchPending, 0) == 1 {
// Small delay to allow DB/cache writes to settle
time.Sleep(500 * time.Millisecond)
doPrefetchCycleGuarded(client, baseURL)
}
}()
doPrefetchCycle(client, baseURL)
if !atomic.CompareAndSwapInt32(&prefetchInFlight, 0, 1) {
// Mark a rerun so we don't lose triggers (e.g., from setup) while a cycle is running
atomic.StoreInt32(&prefetchPending, 1)
log.Printf("[prefetch] in-flight: marked pending rerun")
return
}
defer func() {
atomic.StoreInt32(&prefetchInFlight, 0)
// If a trigger arrived during this run, execute one more cycle immediately
if atomic.SwapInt32(&prefetchPending, 0) == 1 {
// Small delay to allow DB/cache writes to settle
time.Sleep(500 * time.Millisecond)
doPrefetchCycleGuarded(client, baseURL)
}
}()
doPrefetchCycle(client, baseURL)
}
func isDuringMatch(cacheDir string) bool {
@@ -935,7 +974,7 @@ func fetchYouTubeChannel(channel string) error {
func RegenerateFlatGalleryFiles() error {
cacheDir := filepath.Join("cache", "prefetch")
albumsFile := filepath.Join(cacheDir, "zonerama_albums.json")
// Read albums
data, err := os.ReadFile(albumsFile)
if err != nil {
@@ -945,7 +984,7 @@ func RegenerateFlatGalleryFiles() error {
emptyJSON, _ := json.MarshalIndent(emptyList, "", " ")
_ = os.WriteFile(filepath.Join(cacheDir, "gallery.json"), emptyJSON, 0644)
_ = os.WriteFile(filepath.Join(cacheDir, "zonerama_flat.json"), emptyJSON, 0644)
hdr := map[string]string{
"fetched_at": time.Now().Format(time.RFC3339),
"link": "",
@@ -956,7 +995,7 @@ func RegenerateFlatGalleryFiles() error {
}
return fmt.Errorf("failed to read albums: %w", err)
}
// Define album and photo structures
type ZoneramaPhoto struct {
ID string `json:"id"`
@@ -970,26 +1009,26 @@ func RegenerateFlatGalleryFiles() error {
Photos []ZoneramaPhoto `json:"photos"`
FetchedAt string `json:"fetched_at,omitempty"`
}
var albums []ZoneramaAlbum
if err := json.Unmarshal(data, &albums); err != nil {
return fmt.Errorf("failed to parse albums: %w", err)
}
// Flatten all photos from all albums
type FlatPhoto struct {
ID string `json:"id"`
AlbumID string `json:"album_id"`
PageURL string `json:"page_url"`
Local string `json:"local"`
Src string `json:"src"`
Title string `json:"title"`
ID string `json:"id"`
AlbumID string `json:"album_id"`
PageURL string `json:"page_url"`
Local string `json:"local"`
Src string `json:"src"`
Title string `json:"title"`
}
var flatPhotos []FlatPhoto
var latestFetchTime time.Time
var sourceLink string
for _, album := range albums {
// Track the most recent fetch time
if album.FetchedAt != "" {
@@ -1000,7 +1039,7 @@ func RegenerateFlatGalleryFiles() error {
}
}
}
for _, photo := range album.Photos {
flatPhotos = append(flatPhotos, FlatPhoto{
ID: photo.ID,
@@ -1012,7 +1051,7 @@ func RegenerateFlatGalleryFiles() error {
})
}
}
// Write gallery.json
galleryJSON, err := json.MarshalIndent(flatPhotos, "", " ")
if err != nil {
@@ -1021,18 +1060,18 @@ func RegenerateFlatGalleryFiles() error {
if err := os.WriteFile(filepath.Join(cacheDir, "gallery.json"), galleryJSON, 0644); err != nil {
return fmt.Errorf("failed to write gallery.json: %w", err)
}
// Write zonerama_flat.json (same content for compatibility)
if err := os.WriteFile(filepath.Join(cacheDir, "zonerama_flat.json"), galleryJSON, 0644); err != nil {
return fmt.Errorf("failed to write zonerama_flat.json: %w", err)
}
// Write header file with metadata
fetchedAt := latestFetchTime.Format(time.RFC3339)
if fetchedAt == "0001-01-01T00:00:00Z" {
fetchedAt = time.Now().Format(time.RFC3339)
}
hdr := map[string]string{
"fetched_at": fetchedAt,
"link": sourceLink,
@@ -1044,7 +1083,7 @@ func RegenerateFlatGalleryFiles() error {
if err := os.WriteFile(filepath.Join(cacheDir, "zonerama_flat.json.hdr"), hdrJSON, 0644); err != nil {
return fmt.Errorf("failed to write header: %w", err)
}
log.Printf("[gallery] Regenerated flat gallery files: %d photos from %d albums", len(flatPhotos), len(albums))
return nil
}
@@ -1120,3 +1159,207 @@ func fetchToFile(client *http.Client, url string, outPath string) error {
}
return nil
}
// postProcessFACRClubInfoLogos rewrites home_logo_url/away_logo_url in facr_club_info.json
// to point to locally cached transparent PNGs produced by rembg.
// Best-effort: logs warning on failure and preserves original file.
func postProcessFACRClubInfoLogos(cacheDir string) error {
p := filepath.Join(cacheDir, "facr_club_info.json")
b, err := os.ReadFile(p)
if err != nil {
return err
}
// Fast check: if file is tiny or empty, skip
if len(strings.TrimSpace(string(b))) == 0 {
return fmt.Errorf("facr_club_info.json empty")
}
// Parse into a minimal shape we care about
var payload struct {
Competitions []struct {
Matches []struct {
HomeLogoURL string `json:"home_logo_url"`
AwayLogoURL string `json:"away_logo_url"`
} `json:"matches"`
} `json:"competitions"`
}
if err := json.Unmarshal(b, &payload); err != nil {
return fmt.Errorf("parse facr_club_info: %w", err)
}
// Build a map of original -> processed URL to avoid duplicate processing
seen := make(map[string]string, 64)
changed := false
// Walk and rewrite
for ci := range payload.Competitions {
for mi := range payload.Competitions[ci].Matches {
h := strings.TrimSpace(payload.Competitions[ci].Matches[mi].HomeLogoURL)
if h != "" {
if rep, ok := seen[h]; ok {
if rep != h && rep != "" {
payload.Competitions[ci].Matches[mi].HomeLogoURL = rep
changed = true
}
} else {
if newURL, err := ProcessFACRLogo(h); err == nil && newURL != "" && newURL != h {
seen[h] = newURL
payload.Competitions[ci].Matches[mi].HomeLogoURL = newURL
changed = true
} else {
seen[h] = h
}
}
}
a := strings.TrimSpace(payload.Competitions[ci].Matches[mi].AwayLogoURL)
if a != "" {
if rep, ok := seen[a]; ok {
if rep != a && rep != "" {
payload.Competitions[ci].Matches[mi].AwayLogoURL = rep
changed = true
}
} else {
if newURL, err := ProcessFACRLogo(a); err == nil && newURL != "" && newURL != a {
seen[a] = newURL
payload.Competitions[ci].Matches[mi].AwayLogoURL = newURL
changed = true
} else {
seen[a] = a
}
}
}
}
}
if !changed {
return nil
}
// Merge back into original JSON to preserve other fields
// Unmarshal original into generic map, then overlay changes
var orig map[string]any
if err := json.Unmarshal(b, &orig); err != nil {
return fmt.Errorf("parse orig for merge: %w", err)
}
// Rebuild competitions with rewritten logos
if comps, ok := orig["competitions"].([]any); ok {
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
}
// Safely get strings
hv := ""
av := ""
if s, ok3 := m["home_logo_url"].(string); ok3 {
hv = s
}
if s, ok3 := m["away_logo_url"].(string); ok3 {
av = s
}
if rep, ok3 := seen[hv]; ok3 && rep != "" && rep != hv {
m["home_logo_url"] = rep
}
if rep, ok3 := seen[av]; ok3 && rep != "" && rep != av {
m["away_logo_url"] = rep
}
}
}
}
}
// Write back atomically
if err := writeJSONAtomic(p, orig); err != nil {
return err
}
return nil
}
// postProcessFACRTablesLogos rewrites team_logo_url entries in facr_tables.json
// to point to locally cached transparent PNGs produced by rembg.
// Best-effort: logs warning on failure and preserves original file.
func postProcessFACRTablesLogos(cacheDir string) error {
p := filepath.Join(cacheDir, "facr_tables.json")
b, err := os.ReadFile(p)
if err != nil {
return err
}
// Fast check: if file is tiny or empty, skip
if len(strings.TrimSpace(string(b))) == 0 {
return fmt.Errorf("facr_tables.json empty")
}
// Parse minimal shape
var payload struct {
Competitions []struct {
Table struct {
Overall []struct {
TeamLogoURL string `json:"team_logo_url"`
} `json:"overall"`
} `json:"table"`
} `json:"competitions"`
}
if err := json.Unmarshal(b, &payload); err != nil {
return fmt.Errorf("parse facr_tables: %w", err)
}
seen := make(map[string]string, 64)
changed := false
for ci := range payload.Competitions {
rows := payload.Competitions[ci].Table.Overall
for ri := range rows {
s := strings.TrimSpace(rows[ri].TeamLogoURL)
if s == "" {
continue
}
if rep, ok := seen[s]; ok {
if rep != "" && rep != s {
payload.Competitions[ci].Table.Overall[ri].TeamLogoURL = rep
changed = true
}
} else {
if purl, err := ProcessFACRLogo(s); err == nil && strings.TrimSpace(purl) != "" && purl != s {
seen[s] = purl
payload.Competitions[ci].Table.Overall[ri].TeamLogoURL = purl
changed = true
} else {
seen[s] = s
}
}
}
}
if !changed {
return nil
}
// Merge back into original JSON preserving other fields
var orig map[string]any
if err := json.Unmarshal(b, &orig); err != nil {
return fmt.Errorf("parse orig for merge: %w", err)
}
if comps, ok := orig["competitions"].([]any); ok {
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 {
if rep, ok := seen[s]; ok && rep != "" && rep != s {
row["team_logo_url"] = rep
}
}
}
}
}
if err := writeJSONAtomic(p, orig); err != nil {
return err
}
return nil
}