Files
2026-05-30 01:35:50 +02:00

183 lines
4.4 KiB
Go

package dxcc
import (
"context"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
)
// CtyDatURL is the canonical source of cty.dat. AD1C ships updates roughly
// monthly; we cache the file on disk so we don't hammer it.
const CtyDatURL = "https://www.country-files.com/cty/cty.dat"
// Manager owns the on-disk cty.dat cache and the parsed DB. Safe for
// concurrent reads after Load; concurrent reloads serialize on its lock.
type Manager struct {
cacheDir string
mu sync.RWMutex
db *DB
src ctySource // metadata about whichever copy we loaded
loading atomic.Bool
}
type ctySource struct {
Path string `json:"path"`
LoadedAt time.Time `json:"loaded_at"`
FileModTime time.Time `json:"file_mod_time"`
Entities int `json:"entities"`
Downloaded bool `json:"downloaded"`
}
// NewManager prepares a manager rooted at cacheDir (created if missing).
// Does not load anything — call EnsureLoaded after.
func NewManager(cacheDir string) *Manager {
return &Manager{cacheDir: cacheDir}
}
// Path returns the on-disk path where cty.dat is/should be cached.
func (m *Manager) Path() string {
return filepath.Join(m.cacheDir, "cty.dat")
}
// EnsureLoaded loads cty.dat from disk; if missing, downloads it first.
// Safe to call repeatedly — only the first run actually downloads.
func (m *Manager) EnsureLoaded(ctx context.Context) error {
if _, err := os.Stat(m.Path()); os.IsNotExist(err) {
if err := m.Download(ctx); err != nil {
return fmt.Errorf("download cty.dat: %w", err)
}
}
return m.LoadFromDisk()
}
// LoadFromDisk parses the cached cty.dat into a fresh DB and swaps it in.
func (m *Manager) LoadFromDisk() error {
f, err := os.Open(m.Path())
if err != nil {
return err
}
defer f.Close()
info, _ := f.Stat()
db, err := Load(f)
if err != nil {
return err
}
m.mu.Lock()
m.db = db
m.src = ctySource{
Path: m.Path(),
LoadedAt: time.Now(),
FileModTime: info.ModTime(),
Entities: len(db.entities),
}
m.mu.Unlock()
return nil
}
// Download fetches a fresh cty.dat from CtyDatURL and atomically replaces
// the on-disk cache. Does NOT reload it into memory — caller can chain
// LoadFromDisk for that.
func (m *Manager) Download(ctx context.Context) error {
if !m.loading.CompareAndSwap(false, true) {
return fmt.Errorf("cty.dat download already in progress")
}
defer m.loading.Store(false)
if err := os.MkdirAll(m.cacheDir, 0o755); err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, "GET", CtyDatURL, nil)
if err != nil {
return err
}
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("HTTP %d", resp.StatusCode)
}
// Write to a temp file in the same dir, then atomic rename — avoids a
// half-written file if we crash mid-download.
tmp, err := os.CreateTemp(m.cacheDir, "cty-*.tmp")
if err != nil {
return err
}
tmpPath := tmp.Name()
_, err = io.Copy(tmp, resp.Body)
tmp.Close()
if err != nil {
os.Remove(tmpPath)
return err
}
if err := os.Rename(tmpPath, m.Path()); err != nil {
os.Remove(tmpPath)
return err
}
return nil
}
// Refresh = Download + LoadFromDisk in one call.
func (m *Manager) Refresh(ctx context.Context) error {
if err := m.Download(ctx); err != nil {
return err
}
return m.LoadFromDisk()
}
// Lookup is a passthrough to the loaded DB. Returns false if no DB is
// loaded yet (callers should treat that as graceful degradation).
func (m *Manager) Lookup(callsign string) (Match, bool) {
m.mu.RLock()
db := m.db
m.mu.RUnlock()
if db == nil {
return Match{}, false
}
return db.Lookup(callsign)
}
// EntityNames returns the sorted, de-duplicated DXCC entity names from the
// loaded cty.dat — the canonical list for a "Country" picker. Empty until
// cty.dat has loaded.
func (m *Manager) EntityNames() []string {
m.mu.RLock()
db := m.db
m.mu.RUnlock()
if db == nil {
return nil
}
seen := map[string]bool{}
var out []string
for _, e := range db.Entities() {
n := strings.TrimSpace(e.Name)
if n == "" || seen[n] {
continue
}
seen[n] = true
out = append(out, n)
}
sort.Strings(out)
return out
}
// Info returns metadata about the currently-loaded cty.dat (or zero value
// if nothing loaded).
func (m *Manager) Info() ctySource {
m.mu.RLock()
defer m.mu.RUnlock()
return m.src
}