2026-03-03 01:16:04 -05:00
|
|
|
package api
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"crypto/sha256"
|
|
|
|
|
"encoding/hex"
|
|
|
|
|
"os"
|
|
|
|
|
"path/filepath"
|
2026-03-03 02:29:34 -05:00
|
|
|
"strings"
|
|
|
|
|
"sync/atomic"
|
|
|
|
|
"time"
|
2026-03-03 01:16:04 -05:00
|
|
|
)
|
|
|
|
|
|
2026-03-03 02:29:34 -05:00
|
|
|
// CacheStats tracks cache hit/miss statistics.
|
|
|
|
|
type CacheStats struct {
|
|
|
|
|
Hits int64
|
|
|
|
|
Misses int64
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// FileCache implements a file-backed HTTP response cache with TTL expiry.
|
2026-03-03 01:16:04 -05:00
|
|
|
type FileCache struct {
|
2026-03-03 02:29:34 -05:00
|
|
|
dir string
|
|
|
|
|
stats CacheStats
|
2026-03-03 01:16:04 -05:00
|
|
|
}
|
|
|
|
|
|
2026-03-03 02:29:34 -05:00
|
|
|
// Default TTL values.
|
|
|
|
|
const (
|
|
|
|
|
// CacheTTLShort is for current-season, frequently changing data (meetings, sessions, results).
|
|
|
|
|
CacheTTLShort = 15 * time.Minute
|
|
|
|
|
// CacheTTLMedium is for semi-stable data (championship standings, driver lists).
|
|
|
|
|
CacheTTLMedium = 1 * time.Hour
|
|
|
|
|
// CacheTTLLong is for historical data that rarely changes (past season data).
|
|
|
|
|
CacheTTLLong = 24 * time.Hour
|
|
|
|
|
)
|
|
|
|
|
|
2026-03-03 01:16:04 -05:00
|
|
|
func NewFileCache() *FileCache {
|
|
|
|
|
var cacheDir string
|
|
|
|
|
userCacheDir, err := os.UserCacheDir()
|
|
|
|
|
if err == nil {
|
|
|
|
|
cacheDir = filepath.Join(userCacheDir, "box-box", "openf1")
|
|
|
|
|
} else {
|
|
|
|
|
// Fallback to a local .cache directory
|
|
|
|
|
cacheDir = ".cache/box-box/openf1"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Ensure the cache directory exists
|
|
|
|
|
_ = os.MkdirAll(cacheDir, 0755)
|
|
|
|
|
|
|
|
|
|
return &FileCache{
|
|
|
|
|
dir: cacheDir,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c *FileCache) getCachePath(key string) string {
|
|
|
|
|
hash := sha256.Sum256([]byte(key))
|
|
|
|
|
filename := hex.EncodeToString(hash[:]) + ".json"
|
|
|
|
|
return filepath.Join(c.dir, filename)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-03 02:29:34 -05:00
|
|
|
// ttlForURL determines the appropriate TTL based on the URL pattern.
|
|
|
|
|
func ttlForURL(url string) time.Duration {
|
|
|
|
|
// Historical data (specific year queries for past years)
|
|
|
|
|
if strings.Contains(url, "year=2023") || strings.Contains(url, "year=2024") {
|
|
|
|
|
return CacheTTLLong
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Frequently changing endpoints
|
|
|
|
|
if strings.Contains(url, "/position") ||
|
|
|
|
|
strings.Contains(url, "/intervals") ||
|
|
|
|
|
strings.Contains(url, "/car_data") ||
|
|
|
|
|
strings.Contains(url, "/location") {
|
|
|
|
|
return CacheTTLShort
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Semi-stable data
|
|
|
|
|
if strings.Contains(url, "/championship") ||
|
|
|
|
|
strings.Contains(url, "/drivers") {
|
|
|
|
|
return CacheTTLMedium
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Default: medium TTL for everything else
|
|
|
|
|
return CacheTTLMedium
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Get retrieves data from the cache. Returns nil, false if not found or expired.
|
2026-03-03 01:16:04 -05:00
|
|
|
func (c *FileCache) Get(key string) ([]byte, bool) {
|
|
|
|
|
path := c.getCachePath(key)
|
2026-03-03 02:29:34 -05:00
|
|
|
info, err := os.Stat(path)
|
|
|
|
|
if err != nil {
|
|
|
|
|
atomic.AddInt64(&c.stats.Misses, 1)
|
|
|
|
|
return nil, false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check TTL based on file modification time
|
|
|
|
|
ttl := ttlForURL(key)
|
|
|
|
|
if time.Since(info.ModTime()) > ttl {
|
|
|
|
|
// Expired — remove the stale file
|
|
|
|
|
_ = os.Remove(path)
|
|
|
|
|
atomic.AddInt64(&c.stats.Misses, 1)
|
|
|
|
|
return nil, false
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-03 01:16:04 -05:00
|
|
|
data, err := os.ReadFile(path)
|
|
|
|
|
if err != nil {
|
2026-03-03 02:29:34 -05:00
|
|
|
atomic.AddInt64(&c.stats.Misses, 1)
|
2026-03-03 01:16:04 -05:00
|
|
|
return nil, false
|
|
|
|
|
}
|
2026-03-03 02:29:34 -05:00
|
|
|
|
|
|
|
|
atomic.AddInt64(&c.stats.Hits, 1)
|
2026-03-03 01:16:04 -05:00
|
|
|
return data, true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Set saves data to the cache.
|
|
|
|
|
func (c *FileCache) Set(key string, data []byte) error {
|
|
|
|
|
path := c.getCachePath(key)
|
|
|
|
|
return os.WriteFile(path, data, 0644)
|
|
|
|
|
}
|
2026-03-03 02:29:34 -05:00
|
|
|
|
|
|
|
|
// Stats returns current cache hit/miss stats.
|
|
|
|
|
func (c *FileCache) Stats() CacheStats {
|
|
|
|
|
return CacheStats{
|
|
|
|
|
Hits: atomic.LoadInt64(&c.stats.Hits),
|
|
|
|
|
Misses: atomic.LoadInt64(&c.stats.Misses),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Clear removes all cached files.
|
|
|
|
|
func (c *FileCache) Clear() error {
|
|
|
|
|
entries, err := os.ReadDir(c.dir)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
for _, entry := range entries {
|
|
|
|
|
if strings.HasSuffix(entry.Name(), ".json") {
|
|
|
|
|
_ = os.Remove(filepath.Join(c.dir, entry.Name()))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Size returns the number of cached files and total size in bytes.
|
|
|
|
|
func (c *FileCache) Size() (int, int64) {
|
|
|
|
|
entries, err := os.ReadDir(c.dir)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return 0, 0
|
|
|
|
|
}
|
|
|
|
|
count := 0
|
|
|
|
|
var totalSize int64
|
|
|
|
|
for _, entry := range entries {
|
|
|
|
|
if strings.HasSuffix(entry.Name(), ".json") {
|
|
|
|
|
count++
|
|
|
|
|
info, err := entry.Info()
|
|
|
|
|
if err == nil {
|
|
|
|
|
totalSize += info.Size()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return count, totalSize
|
|
|
|
|
}
|