// Package lookup queries callsign databases (QRZ.com, HamQTH) and caches // results locally so we don't re-hit the network for known calls. package lookup import ( "context" "database/sql" "errors" "fmt" "strings" "sync" "time" ) // ErrNotFound is returned by providers when a callsign is unknown. var ErrNotFound = errors.New("callsign not found") // Result is the normalized lookup output regardless of provider. type Result struct { Callsign string `json:"callsign"` Name string `json:"name,omitempty"` QTH string `json:"qth,omitempty"` Address string `json:"address,omitempty"` State string `json:"state,omitempty"` County string `json:"cnty,omitempty"` Country string `json:"country,omitempty"` Grid string `json:"grid,omitempty"` Lat float64 `json:"lat,omitempty"` Lon float64 `json:"lon,omitempty"` DXCC int `json:"dxcc,omitempty"` CQZ int `json:"cqz,omitempty"` ITUZ int `json:"ituz,omitempty"` Continent string `json:"cont,omitempty"` Email string `json:"email,omitempty"` QSLVia string `json:"qsl_via,omitempty"` ImageURL string `json:"image_url,omitempty"` // profile picture URL (QRZ only for now) Source string `json:"source"` // "qrz", "hamqth", or "cache" FetchedAt time.Time `json:"fetched_at"` } // Provider is the contract implemented by QRZ, HamQTH, etc. type Provider interface { Name() string Lookup(ctx context.Context, callsign string) (Result, error) } // DXCCResolver fills the country / zones / continent when the providers // don't (or when no provider returned anything). Decoupled via interface so // `lookup` doesn't import the dxcc package directly. type DXCCResolver interface { Resolve(callsign string) (country, continent string, cqz, ituz int, lat, lon float64, ok bool) } // Manager composes a cache with one or more providers. // Lookup tries the cache first, then each enabled provider in order. type Manager struct { mu sync.RWMutex providers []Provider cache *Cache dxcc DXCCResolver } func NewManager(cache *Cache) *Manager { return &Manager{cache: cache} } // SetDXCCResolver wires the cty.dat-backed fallback that fills country/ // zones when the provider chain comes up dry or short. func (m *Manager) SetDXCCResolver(r DXCCResolver) { m.mu.Lock() defer m.mu.Unlock() m.dxcc = r } // SetProviders replaces the provider chain. Safe to call at any time // (e.g. after the user updates credentials in settings). func (m *Manager) SetProviders(p ...Provider) { m.mu.Lock() defer m.mu.Unlock() m.providers = p } // Lookup returns a Result for the callsign. Falls back through providers // when one returns ErrNotFound or fails. func (m *Manager) Lookup(ctx context.Context, callsign string) (Result, error) { call := strings.ToUpper(strings.TrimSpace(callsign)) if call == "" { return Result{}, fmt.Errorf("empty callsign") } if r, ok := m.cache.Get(ctx, call); ok { r.Source = "cache" return r, nil } m.mu.RLock() providers := append([]Provider(nil), m.providers...) dxcc := m.dxcc m.mu.RUnlock() var lastErr error for _, p := range providers { r, err := p.Lookup(ctx, call) if err == nil { r.Callsign = call r.Source = p.Name() r.FetchedAt = time.Now().UTC() fillFromDXCC(&r, dxcc) _ = m.cache.Put(ctx, r) return r, nil } if errors.Is(err, ErrNotFound) { lastErr = err continue } lastErr = fmt.Errorf("%s: %w", p.Name(), err) } // All providers exhausted (not-found or errored). Try the cty.dat // resolver as a last resort — at least we can hand back country/zones // even for unknown callsigns. Not cached: a "cty.dat-only" result // shouldn't suppress a later real lookup if the user adds creds. if dxcc != nil { var r Result r.Callsign = call if fillFromDXCC(&r, dxcc) { r.Source = "cty.dat" r.FetchedAt = time.Now().UTC() return r, nil } } if lastErr == nil { if len(providers) == 0 { lastErr = fmt.Errorf("no lookup provider configured") } else { lastErr = ErrNotFound } } return Result{}, lastErr } // fillFromDXCC fills (or overrides) country/continent/zones/lat/lon from // the cty.dat resolver. cty.dat is the authoritative source for DXCC // mapping, so Country/Continent/CQZ/ITUZ are ALWAYS overridden when it // has an answer — QRZ tends to return the political country (Greece for // SV5*, Russia for UA9*) instead of the DXCC entity (Dodecanese, // Asiatic Russia). Lat/Lon are filled only when empty so a more precise // home QTH from QRZ wins over the cty.dat entity centroid. // // For slashed callsigns (IT9/DK6XZ, DL/F4NIE…) the provider returned the // home-call's entity which is wrong for portable operations; we keep the // Name/QTH/Address from the provider (still useful for QSL) but reset // the DXCC number since QRZ's value is wrong and we don't have an entity // → DXCC# table yet. // Returns true if any field was filled. func fillFromDXCC(r *Result, dxcc DXCCResolver) bool { if dxcc == nil { return false } country, cont, cqz, ituz, lat, lon, ok := dxcc.Resolve(r.Callsign) if !ok { return false } filled := false if country != "" { r.Country = country; filled = true } if cont != "" { r.Continent = cont; filled = true } if cqz != 0 { r.CQZ = cqz; filled = true } if ituz != 0 { r.ITUZ = ituz; filled = true } if lat != 0 && r.Lat == 0 { r.Lat = lat; filled = true } if lon != 0 && r.Lon == 0 { r.Lon = lon; filled = true } // Slashed call → drop QRZ's DXCC# (it's the home call's). if strings.ContainsRune(r.Callsign, '/') && r.DXCC != 0 { r.DXCC = 0 filled = true } return filled } // ----- Cache ----- // Cache is a SQLite-backed cache of lookup results with a TTL. type Cache struct { db *sql.DB ttl time.Duration } func NewCache(db *sql.DB, ttl time.Duration) *Cache { if ttl <= 0 { ttl = 30 * 24 * time.Hour } return &Cache{db: db, ttl: ttl} } // SetTTL updates the cache TTL (e.g. when user changes settings). func (c *Cache) SetTTL(ttl time.Duration) { if ttl > 0 { c.ttl = ttl } } // Get returns the cached result if present and not expired. func (c *Cache) Get(ctx context.Context, callsign string) (Result, bool) { row := c.db.QueryRowContext(ctx, ` SELECT callsign, name, qth, address, state, cnty, country, grid, lat, lon, dxcc, cqz, ituz, cont, email, qsl_via, image_url, source, fetched_at FROM callsign_cache WHERE callsign = ?`, callsign) var ( r Result name, qth, addr, state, cnty sql.NullString country, grid, cont, email, qslVia, image sql.NullString src string dxcc, cqz, ituz sql.NullInt64 lat, lon sql.NullFloat64 fetched string ) if err := row.Scan(&r.Callsign, &name, &qth, &addr, &state, &cnty, &country, &grid, &lat, &lon, &dxcc, &cqz, &ituz, &cont, &email, &qslVia, &image, &src, &fetched); err != nil { return Result{}, false } t, err := time.Parse("2006-01-02T15:04:05.000Z", fetched) if err != nil { t, _ = time.Parse(time.RFC3339, fetched) } if time.Since(t) > c.ttl { return Result{}, false } r.Name = name.String r.QTH = qth.String r.Address = addr.String r.State = state.String r.County = cnty.String r.Country = country.String r.Grid = grid.String r.Lat = lat.Float64 r.Lon = lon.Float64 r.Continent = cont.String r.Email = email.String r.QSLVia = qslVia.String r.ImageURL = image.String r.DXCC = int(dxcc.Int64) r.CQZ = int(cqz.Int64) r.ITUZ = int(ituz.Int64) r.Source = src r.FetchedAt = t return r, true } // Put upserts a lookup result. func (c *Cache) Put(ctx context.Context, r Result) error { _, err := c.db.ExecContext(ctx, ` INSERT INTO callsign_cache(callsign, name, qth, address, state, cnty, country, grid, lat, lon, dxcc, cqz, ituz, cont, email, qsl_via, image_url, source, fetched_at) VALUES(?,?,?,?,?,?, ?,?,?,?, ?,?,?,?,?,?,?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) ON CONFLICT(callsign) DO UPDATE SET name = excluded.name, qth = excluded.qth, address = excluded.address, state = excluded.state, cnty = excluded.cnty, country = excluded.country, grid = excluded.grid, lat = excluded.lat, lon = excluded.lon, dxcc = excluded.dxcc, cqz = excluded.cqz, ituz = excluded.ituz, cont = excluded.cont, email = excluded.email, qsl_via = excluded.qsl_via, image_url = excluded.image_url, source = excluded.source, fetched_at = excluded.fetched_at`, r.Callsign, nullable(r.Name), nullable(r.QTH), nullable(r.Address), nullable(r.State), nullable(r.County), nullable(r.Country), nullable(r.Grid), nullableFloat(r.Lat), nullableFloat(r.Lon), nullableInt(r.DXCC), nullableInt(r.CQZ), nullableInt(r.ITUZ), nullable(r.Continent), nullable(r.Email), nullable(r.QSLVia), nullable(r.ImageURL), r.Source, ) return err } func nullableFloat(f float64) any { if f == 0 { return nil } return f } // Clear empties the cache. Useful for "Refresh cache" admin actions. func (c *Cache) Clear(ctx context.Context) error { _, err := c.db.ExecContext(ctx, `DELETE FROM callsign_cache`) return err } func nullable(s string) any { if s == "" { return nil } return s } func nullableInt(n int) any { if n == 0 { return nil } return n }