This commit is contained in:
Tomáš Dvořák
2025-10-16 13:32:05 +02:00
commit 12cba639b9
663 changed files with 168914 additions and 0 deletions
@@ -0,0 +1,599 @@
package controllers
import (
"net/http"
"encoding/json"
"os"
"path/filepath"
"time"
"fmt"
"strings"
"io"
"image"
_ "image/png"
_ "image/jpeg"
_ "image/gif"
"net/http/httputil"
"fotbal-club/internal/models"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// ScoreboardController manages the singleton scoreboard state
type ScoreboardController struct {
DB *gorm.DB
}
// --- Additional features ported from MyClub ScoreBoard ---
// makeShort derives a 3-letter uppercase abbreviation from a club name.
func makeShort(name string) string {
name = strings.TrimSpace(name)
if name == "" { return "---" }
name = strings.ToUpper(name)
repl := strings.NewReplacer(
"Á", "A", "Ä", "A", "Å", "A", "Â", "A", "À", "A",
"Č", "C", "Ć", "C", "Ç", "C",
"Ď", "D",
"É", "E", "Ě", "E", "È", "E", "Ë", "E", "Ê", "E",
"Í", "I", "Ì", "I", "Ï", "I", "Î", "I",
"Ň", "N", "Ń", "N",
"Ó", "O", "Ö", "O", "Ô", "O", "Ò", "O",
"Ř", "R",
"Š", "S", "Ś", "S",
"Ť", "T",
"Ú", "U", "Ů", "U", "Ù", "U", "Ü", "U", "Û", "U",
"Ý", "Y",
"Ž", "Z",
)
name = repl.Replace(name)
out := make([]rune, 0, 3)
for _, r := range name {
if r >= 'A' && r <= 'Z' {
out = append(out, r)
if len(out) == 3 { break }
}
}
for len(out) < 3 { out = append(out, '-') }
return string(out)
}
// DeriveColors returns average dominant colors from provided logo URLs
func (c *ScoreboardController) DeriveColors(ctx *gin.Context) {
type req struct {
URL string `json:"url"`
HomeLogo string `json:"homeLogo"`
AwayLogo string `json:"awayLogo"`
}
type singleResp struct{ Color string `json:"color"` }
type duoResp struct{ PrimaryColor string `json:"primaryColor"`; SecondaryColor string `json:"secondaryColor"` }
var q req
q.URL = ctx.Query("url")
q.HomeLogo = ctx.Query("homeLogo")
q.AwayLogo = ctx.Query("awayLogo")
if q.URL == "" && q.HomeLogo == "" && q.AwayLogo == "" {
// try JSON body
_ = ctx.ShouldBindJSON(&q)
}
if q.URL != "" {
col, err := averageColorFromURL(q.URL)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "cannot fetch or process image"})
return
}
ctx.JSON(http.StatusOK, singleResp{Color: col})
return
}
if q.HomeLogo != "" || q.AwayLogo != "" {
var primary, secondary string
if q.HomeLogo != "" {
if col, err := averageColorFromURL(q.HomeLogo); err == nil { primary = col }
}
if q.AwayLogo != "" {
if col, err := averageColorFromURL(q.AwayLogo); err == nil { secondary = col }
}
ctx.JSON(http.StatusOK, duoResp{PrimaryColor: primary, SecondaryColor: secondary})
return
}
ctx.JSON(http.StatusBadRequest, gin.H{"error": "provide ?url= or ?homeLogo=&awayLogo="})
}
// averageColorFromURL downloads an image and computes its average RGB color in hex.
func averageColorFromURL(u string) (string, error) {
resp, err := http.Get(u)
if err != nil { return "", err }
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
// best-effort body capture for debugging
b, _ := httputil.DumpResponse(resp, false)
_ = b
return "", fmt.Errorf("http status %d", resp.StatusCode)
}
img, _, err := image.Decode(resp.Body)
if err != nil { return "", err }
return averageHex(img), nil
}
func averageHex(img image.Image) string {
rect := img.Bounds()
if rect.Empty() { return "#000000" }
w := rect.Dx(); h := rect.Dy()
stepX, stepY := 1, 1
for (w/stepX)*(h/stepY) > 160000 {
if stepX <= stepY { stepX *= 2 } else { stepY *= 2 }
}
var rsum, gsum, bsum, count uint64
for y := rect.Min.Y; y < rect.Max.Y; y += stepY {
for x := rect.Min.X; x < rect.Max.X; x += stepX {
cr, cg, cb, ca := img.At(x,y).RGBA()
if ca < 0x2000 { continue }
rsum += uint64(cr >> 8)
gsum += uint64(cg >> 8)
bsum += uint64(cb >> 8)
count++
}
}
if count == 0 { return "#000000" }
r8 := uint8(rsum / count)
g8 := uint8(gsum / count)
b8 := uint8(bsum / count)
return fmt.Sprintf("#%02x%02x%02x", r8, g8, b8)
}
// SwapSides swaps home and away team info including names, logos, shorts, scores and colors.
func (c *ScoreboardController) SwapSides(ctx *gin.Context) {
s, err := c.getOrCreateSingleton()
if err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": "cannot load scoreboard"}); return }
s.HomeName, s.AwayName = s.AwayName, s.HomeName
s.HomeLogoURL, s.AwayLogoURL = s.AwayLogoURL, s.HomeLogoURL
s.HomeScore, s.AwayScore = s.AwayScore, s.HomeScore
s.HomeShort, s.AwayShort = s.AwayShort, s.HomeShort
s.PrimaryColor, s.SecondaryColor = s.SecondaryColor, s.PrimaryColor
if err := c.DB.Save(s).Error; err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save"}); return
}
ctx.JSON(http.StatusOK, gin.H{"ok": true})
}
// StartSecondHalf swaps sides, resets the timer to 00:00 and immediately starts it.
func (c *ScoreboardController) StartSecondHalf(ctx *gin.Context) {
s, err := c.getOrCreateSingleton()
if err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": "cannot load scoreboard"}); return }
// swap first
s.HomeName, s.AwayName = s.AwayName, s.HomeName
s.HomeLogoURL, s.AwayLogoURL = s.AwayLogoURL, s.HomeLogoURL
s.HomeScore, s.AwayScore = s.AwayScore, s.HomeScore
s.HomeShort, s.AwayShort = s.AwayShort, s.HomeShort
s.PrimaryColor, s.SecondaryColor = s.SecondaryColor, s.PrimaryColor
// reset and start timer for next half
s.Running = true
s.ElapsedSeconds = 0
s.TimerStartUnix = time.Now().Unix()
s.Timer = "00:00"
if err := c.DB.Save(s).Error; err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save"}); return
}
ctx.JSON(http.StatusOK, gin.H{"ok": true})
}
// SaveState saves current scoreboard state as a JSON file in /saved directory.
func (c *ScoreboardController) SaveState(ctx *gin.Context) {
type req struct{ Filename string `json:"filename"` }
var q req
_ = ctx.ShouldBindJSON(&q)
if q.Filename == "" { q.Filename = ctx.Query("filename") }
name := sanitizeFilename(q.Filename)
if name == "" { name = time.Now().Format("20060102-150405") }
if !strings.HasSuffix(strings.ToLower(name), ".json") { name += ".json" }
_ = os.MkdirAll("saved", 0o755)
s, err := c.getOrCreateSingleton()
if err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": "cannot load scoreboard"}); return }
b, _ := json.MarshalIndent(s, "", " ")
if err := os.WriteFile(filepath.Join("saved", name), b, 0o644); err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "save failed"}); return
}
ctx.JSON(http.StatusOK, gin.H{"saved": name})
}
// ListSaves returns the list of saved preset filenames from /saved
func (c *ScoreboardController) ListSaves(ctx *gin.Context) {
entries, err := os.ReadDir("saved")
if err != nil { ctx.JSON(http.StatusOK, []string{}) ; return }
out := make([]string, 0, len(entries))
for _, e := range entries {
if e.IsDir() { continue }
name := e.Name()
if strings.HasSuffix(strings.ToLower(name), ".json") { out = append(out, name) }
}
ctx.JSON(http.StatusOK, out)
}
// LoadSaved loads a saved preset from /saved/<filename> and applies it to the singleton.
func (c *ScoreboardController) LoadSaved(ctx *gin.Context) {
// Support filename via query, JSON, or multipart form file upload as raw JSON
filename := sanitizeFilename(ctx.Query("filename"))
var body struct{ Filename string `json:"filename"` }
if filename == "" {
_ = ctx.ShouldBindJSON(&body)
filename = sanitizeFilename(body.Filename)
}
if filename == "" {
// try multipart file upload under field "file"
file, _, err := ctx.Request.FormFile("file")
if err == nil {
defer file.Close()
data, err := io.ReadAll(file)
if err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": "cannot read file"}); return }
var imported models.ScoreboardState
if err := json.Unmarshal(data, &imported); err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON"}); return }
applyImportedState(imported, c, ctx)
return
}
ctx.JSON(http.StatusBadRequest, gin.H{"error": "missing filename"})
return
}
if !strings.HasSuffix(strings.ToLower(filename), ".json") { filename += ".json" }
path := filepath.Join("saved", filename)
data, err := os.ReadFile(path)
if err != nil { ctx.JSON(http.StatusNotFound, gin.H{"error": "file not found"}); return }
var imported models.ScoreboardState
if err := json.Unmarshal(data, &imported); err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON"}); return }
applyImportedState(imported, c, ctx)
}
func applyImportedState(imported models.ScoreboardState, c *ScoreboardController, ctx *gin.Context) {
s, err := c.getOrCreateSingleton()
if err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": "cannot load scoreboard"}); return }
// overwrite relevant fields
s.HomeName = imported.HomeName
s.AwayName = imported.AwayName
s.HomeLogoURL = imported.HomeLogoURL
s.AwayLogoURL = imported.AwayLogoURL
// derive shorts if empty
if strings.TrimSpace(imported.HomeShort) != "" { s.HomeShort = imported.HomeShort } else { s.HomeShort = makeShort(s.HomeName) }
if strings.TrimSpace(imported.AwayShort) != "" { s.AwayShort = imported.AwayShort } else { s.AwayShort = makeShort(s.AwayName) }
if imported.PrimaryColor != "" { s.PrimaryColor = imported.PrimaryColor }
if imported.SecondaryColor != "" { s.SecondaryColor = imported.SecondaryColor }
s.HomeScore = imported.HomeScore
s.AwayScore = imported.AwayScore
if imported.HalfLength > 0 { s.HalfLength = imported.HalfLength }
if imported.Theme != "" { s.Theme = imported.Theme }
// timer handling
base := parseTimerToSeconds(imported.Timer)
s.Timer = fmt.Sprintf("%02d:%02d", base/60, base%60)
s.ElapsedSeconds = base
if imported.Running {
s.Running = true
s.TimerStartUnix = time.Now().Unix() - int64(base)
} else {
s.Running = false
s.TimerStartUnix = 0
}
if err := c.DB.Save(s).Error; err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save"}); return }
ctx.JSON(http.StatusOK, gin.H{"ok": true})
}
// sanitizeFilename keeps only [a-zA-Z0-9-_] and dots, strips path separators
func sanitizeFilename(in string) string {
in = strings.TrimSpace(in)
in = strings.ReplaceAll(in, "\\", "")
in = strings.ReplaceAll(in, "/", "")
var b strings.Builder
for _, r := range in {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.' {
b.WriteRune(r)
}
}
return b.String()
}
// --- Timer helpers and handlers ---
func parseTimerToSeconds(timer string) int {
// Expect MM:SS
if len(timer) < 4 {
return 0
}
var m, s int
_, err := fmt.Sscanf(timer, "%d:%d", &m, &s)
if err != nil || m < 0 || s < 0 || s >= 60 {
return 0
}
return m*60 + s
}
func formatSeconds(sec int) string {
if sec < 0 { sec = 0 }
return fmt.Sprintf("%02d:%02d", sec/60, sec%60)
}
func computeTimer(s models.ScoreboardState) (timer string, running bool) {
running = s.Running
base := s.ElapsedSeconds
if s.Running {
now := time.Now().Unix()
if s.TimerStartUnix > 0 {
diff := int(now - s.TimerStartUnix)
if diff > 0 { base = diff } else { base = 0 }
}
}
// Cap by half length
cap := s.HalfLength * 60
if cap <= 0 { cap = 45 * 60 }
if base >= cap {
base = cap
running = false
}
timer = formatSeconds(base)
return
}
// StartTimer sets running=true and backdates TimerStartUnix
func (c *ScoreboardController) StartTimer(ctx *gin.Context) {
s, err := c.getOrCreateSingleton()
if err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": "cannot load scoreboard"}); return }
if s.ElapsedSeconds == 0 && s.Timer != "" {
s.ElapsedSeconds = parseTimerToSeconds(s.Timer)
}
s.TimerStartUnix = time.Now().Unix() - int64(s.ElapsedSeconds)
s.Running = true
if err := c.DB.Save(s).Error; err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save"}); return
}
ctx.JSON(http.StatusOK, gin.H{"ok": true})
}
// PauseTimer sets running=false and fixes elapsedSeconds
func (c *ScoreboardController) PauseTimer(ctx *gin.Context) {
s, err := c.getOrCreateSingleton()
if err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": "cannot load scoreboard"}); return }
if s.Running {
now := time.Now().Unix()
if s.TimerStartUnix > 0 {
diff := int(now - s.TimerStartUnix)
if diff > 0 { s.ElapsedSeconds = diff } else { s.ElapsedSeconds = 0 }
}
}
s.Running = false
// Cap and set display string
cap := s.HalfLength * 60
if cap <= 0 { cap = 45 * 60 }
if s.ElapsedSeconds > cap { s.ElapsedSeconds = cap }
s.Timer = formatSeconds(s.ElapsedSeconds)
if err := c.DB.Save(s).Error; err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save"}); return
}
ctx.JSON(http.StatusOK, gin.H{"ok": true})
}
// ResetTimer clears timer to 00:00 and stops it
func (c *ScoreboardController) ResetTimer(ctx *gin.Context) {
s, err := c.getOrCreateSingleton()
if err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": "cannot load scoreboard"}); return }
s.Running = false
s.ElapsedSeconds = 0
s.TimerStartUnix = 0
s.Timer = "00:00"
if err := c.DB.Save(s).Error; err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save"}); return
}
ctx.JSON(http.StatusOK, gin.H{"ok": true})
}
func NewScoreboardController(db *gorm.DB) *ScoreboardController {
return &ScoreboardController{DB: db}
}
func (c *ScoreboardController) getOrCreateSingleton() (*models.ScoreboardState, error) {
var s models.ScoreboardState
if err := c.DB.First(&s).Error; err != nil {
if err == gorm.ErrRecordNotFound {
// Create with sensible defaults
s = models.ScoreboardState{
HomeName: "DOMÁCÍ",
AwayName: "HOSTÉ",
HomeShort: "DOM",
AwayShort: "HOS",
PrimaryColor: "#1e3a8a",
SecondaryColor: "#2563eb",
HalfLength: 45,
Theme: "pill",
Timer: "00:00",
Running: false,
TimerStartUnix: 0,
ElapsedSeconds: 0,
// New fields defaults
SidesFlipped: false,
Half: 1,
QRShowEveryMinutes: 5,
QRShowDurationSeconds: 60,
}
if err := c.DB.Create(&s).Error; err != nil {
return nil, err
}
return &s, nil
}
return nil, err
}
// Ensure defaults for newly added fields when loading existing row
changed := false
if s.Half == 0 { s.Half = 1; changed = true }
if s.QRShowEveryMinutes == 0 { s.QRShowEveryMinutes = 5; changed = true }
if s.QRShowDurationSeconds == 0 { s.QRShowDurationSeconds = 60; changed = true }
if changed { _ = c.DB.Save(&s).Error }
return &s, nil
}
// GetPublic returns read-only scoreboard state for public overlay
func (c *ScoreboardController) GetPublic(ctx *gin.Context) {
s, err := c.getOrCreateSingleton()
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "cannot load scoreboard"})
return
}
timer, running := computeTimer(*s)
ctx.JSON(http.StatusOK, gin.H{
"homeName": s.HomeName,
"awayName": s.AwayName,
"homeLogo": s.HomeLogoURL,
"awayLogo": s.AwayLogoURL,
"homeShort": s.HomeShort,
"awayShort": s.AwayShort,
"primaryColor": s.PrimaryColor,
"secondaryColor": s.SecondaryColor,
"homeScore": s.HomeScore,
"awayScore": s.AwayScore,
"halfLength": s.HalfLength,
"theme": s.Theme,
"external_match_id": s.ExternalMatchID,
"active": s.Active,
// Newly exposed fields compatible with MyClub ScoreBoard overlay
"sidesFlipped": s.SidesFlipped,
"half": s.Half,
"qrEvery": s.QRShowEveryMinutes,
"qrDuration": s.QRShowDurationSeconds,
"timer": timer,
"running": running,
"timer_start_unix": s.TimerStartUnix,
"elapsed_seconds": s.ElapsedSeconds,
})
}
// GetAdmin returns full state for admins
func (c *ScoreboardController) GetAdmin(ctx *gin.Context) {
s, err := c.getOrCreateSingleton()
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "cannot load scoreboard"})
return
}
// Compute transient timer display
timer, running := computeTimer(*s)
out := *s
out.Timer = timer
out.Running = running
ctx.JSON(http.StatusOK, out)
}
// PutAdmin updates the singleton state (admin only)
func (c *ScoreboardController) PutAdmin(ctx *gin.Context) {
var payload struct {
HomeName *string `json:"homeName"`
AwayName *string `json:"awayName"`
HomeLogo *string `json:"homeLogo"`
AwayLogo *string `json:"awayLogo"`
HomeShort *string `json:"homeShort"`
AwayShort *string `json:"awayShort"`
PrimaryColor *string `json:"primaryColor"`
SecondaryColor *string `json:"secondaryColor"`
HomeScore *int `json:"homeScore"`
AwayScore *int `json:"awayScore"`
HalfLength *int `json:"halfLength"`
Theme *string `json:"theme"`
ExternalMatchID *string `json:"externalMatchId"`
Active *bool `json:"active"`
// Optional direct timer patch (when not running)
Timer *string `json:"timer"`
// Newly supported fields (ported from MyClub ScoreBoard)
SidesFlipped *bool `json:"sidesFlipped"`
Half *int `json:"half"`
QRShowEveryMinutes *int `json:"qrEvery"`
QRShowDurationSeconds *int `json:"qrDuration"`
}
if err := ctx.ShouldBindJSON(&payload); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
}
s, err := c.getOrCreateSingleton()
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "cannot load scoreboard"})
return
}
// Apply patch
if payload.HomeName != nil { s.HomeName = *payload.HomeName }
if payload.AwayName != nil { s.AwayName = *payload.AwayName }
if payload.HomeLogo != nil { s.HomeLogoURL = *payload.HomeLogo }
if payload.AwayLogo != nil { s.AwayLogoURL = *payload.AwayLogo }
if payload.HomeShort != nil { s.HomeShort = *payload.HomeShort }
if payload.AwayShort != nil { s.AwayShort = *payload.AwayShort }
if payload.PrimaryColor != nil { s.PrimaryColor = *payload.PrimaryColor }
if payload.SecondaryColor != nil { s.SecondaryColor = *payload.SecondaryColor }
if payload.HomeScore != nil { s.HomeScore = *payload.HomeScore }
if payload.AwayScore != nil { s.AwayScore = *payload.AwayScore }
if payload.HalfLength != nil { s.HalfLength = *payload.HalfLength }
if payload.Theme != nil { s.Theme = *payload.Theme }
if payload.ExternalMatchID != nil { s.ExternalMatchID = *payload.ExternalMatchID }
if payload.Active != nil { s.Active = *payload.Active }
if payload.SidesFlipped != nil { s.SidesFlipped = *payload.SidesFlipped }
if payload.Half != nil { s.Half = *payload.Half }
if payload.QRShowEveryMinutes != nil && *payload.QRShowEveryMinutes > 0 { s.QRShowEveryMinutes = *payload.QRShowEveryMinutes }
if payload.QRShowDurationSeconds != nil && *payload.QRShowDurationSeconds > 0 { s.QRShowDurationSeconds = *payload.QRShowDurationSeconds }
if payload.Timer != nil && !s.Running {
// Set base timer string when paused
s.Timer = *payload.Timer
s.ElapsedSeconds = parseTimerToSeconds(*payload.Timer)
}
if err := c.DB.Save(s).Error; err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save"})
return
}
// Best-effort: if scoreboard active and linked to a match, write live cache
if s.Active && s.ExternalMatchID != "" {
go writeLiveScoreboardCache(s)
}
ctx.JSON(http.StatusOK, s)
}
// writeLiveScoreboardCache writes cache/live/score_<id>.json and patches cache/prefetch/events_upcoming.json if present
func writeLiveScoreboardCache(s *models.ScoreboardState) {
// Ensure directories
_ = os.MkdirAll(filepath.Join("cache", "live"), 0o755)
// Write live file
payload := map[string]any{
"external_match_id": s.ExternalMatchID,
"home": s.HomeName,
"away": s.AwayName,
"home_logo_url": s.HomeLogoURL,
"away_logo_url": s.AwayLogoURL,
"home_score": s.HomeScore,
"away_score": s.AwayScore,
"primary_color": s.PrimaryColor,
"secondary_color": s.SecondaryColor,
"theme": s.Theme,
"half_length": s.HalfLength,
"active": s.Active,
"updated_at": time.Now().Format(time.RFC3339),
}
b, _ := json.MarshalIndent(payload, "", " ")
tmp := filepath.Join("cache", "live", "score_"+s.ExternalMatchID+".json.tmp")
dst := filepath.Join("cache", "live", "score_"+s.ExternalMatchID+".json")
if err := os.WriteFile(tmp, b, 0o644); err == nil { _ = os.Rename(tmp, dst) }
// Patch prefetch events if available
prefetch := filepath.Join("cache", "prefetch", "events_upcoming.json")
f, err := os.Open(prefetch)
if err != nil { return }
defer f.Close()
var arr []map[string]any
if err := json.NewDecoder(f).Decode(&arr); err != nil { return }
for i := range arr {
id := ""
if v, ok := arr[i]["match_id"].(string); ok { id = v }
if id == s.ExternalMatchID {
arr[i]["score"] = map[string]any{"home": s.HomeScore, "away": s.AwayScore}
arr[i]["home_logo_url"] = s.HomeLogoURL
arr[i]["away_logo_url"] = s.AwayLogoURL
arr[i]["home"] = s.HomeName
arr[i]["away"] = s.AwayName
break
}
}
out, _ := json.MarshalIndent(arr, "", " ")
_ = os.WriteFile(prefetch+".tmp", out, 0o644)
_ = os.Rename(prefetch+".tmp", prefetch)
}