Files
MyClub/internal/services/facr_logo_bg_removal.go
T
Tomas Dvorak dfc079288f hot fix #1
2026-01-26 08:13:18 +01:00

241 lines
6.3 KiB
Go

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")
}
// In manual club data mode we avoid any remote FACR/fotbal.cz processing and
// return the original URL unchanged so that LogoAPI/manual overrides can be
// applied on the frontend without additional HTTP calls.
if config.AppConfig != nil {
mode := strings.ToLower(strings.TrimSpace(config.AppConfig.ClubDataMode))
if mode == "manual" {
return u, nil
}
}
// 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
}