package watchlist // ClubLog enrichment — phase 2 of the DXHunter port. clublog.org/watch.php // answers, per callsign: is this an expedition, does it run OQRS, is there a // LiveStream, the log's QSO total and the last 24 hours' rate. That block has // been in the Entry schema since phase 1 (the file round-trips DXHunter's); // this file finally fills it. import ( "context" "encoding/json" "fmt" "io" "net/http" "strings" "time" ) // WatchData is clublog.org/watch.php's answer, reduced to what the tab shows. type WatchData struct { IsExpedition bool `json:"is_expedition"` HasOQRS bool `json:"has_oqrs"` LiveStream bool `json:"livestream"` ClubLogInfo *struct { TotalQSOs int `json:"total_qsos"` } `json:"clublog_info"` // {} when populated, [] when empty — RawMessage swallows both shapes. QSOsPerBand json.RawMessage `json:"24h_qsos_per_band_mode"` } // QSOs24h sums the per-band/per-mode counts. func (d *WatchData) QSOs24h() int { if len(d.QSOsPerBand) == 0 { return 0 } var parsed map[string]map[string]int if err := json.Unmarshal(d.QSOsPerBand, &parsed); err != nil { return 0 // "[]" for an empty answer lands here, which is the right zero } n := 0 for _, modes := range parsed { for _, c := range modes { n += c } } return n } // FetchWatch asks ClubLog about one callsign. A 404 is "ClubLog has no log for // this call" — an ordinary answer, not an error. func FetchWatch(ctx context.Context, client *http.Client, apiKey, callsign string) (*WatchData, error) { url := fmt.Sprintf("https://clublog.org/watch.php?call=%s&api=%s", strings.ToUpper(strings.TrimSpace(callsign)), apiKey) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, err } req.Header.Set("User-Agent", "OpsLog") resp, err := client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode == http.StatusNotFound { return &WatchData{}, nil } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("clublog watch %s: HTTP %d", callsign, resp.StatusCode) } body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if err != nil { return nil, err } if len(strings.TrimSpace(string(body))) == 0 { return &WatchData{}, nil } var data WatchData if err := json.Unmarshal(body, &data); err != nil { return nil, fmt.Errorf("clublog watch %s: %w", callsign, err) } return &data, nil } // ApplyClubLog writes one answer into the entry and reports whether anything // the tab shows actually changed — the caller only broadcasts on true. func (s *Store) ApplyClubLog(callsign string, d *WatchData) bool { s.mu.Lock() defer s.mu.Unlock() e, ok := s.entries[strings.ToUpper(strings.TrimSpace(callsign))] if !ok { return false } total := e.ClubLogTotalQSOs if d.ClubLogInfo != nil { total = d.ClubLogInfo.TotalQSOs } q24 := d.QSOs24h() changed := e.IsExpedition != d.IsExpedition || e.ClubLogHasOQRS != d.HasOQRS || e.ClubLogLiveStream != d.LiveStream || e.ClubLogTotalQSOs != total || e.ClubLogQSOs24h != q24 e.IsExpedition = d.IsExpedition e.ClubLogHasOQRS = d.HasOQRS e.ClubLogLiveStream = d.LiveStream e.ClubLogTotalQSOs = total e.ClubLogQSOs24h = q24 e.ClubLogUpdatedAt = time.Now() s.scheduleSave() return changed } // StaleEntries lists the callsigns whose ClubLog block is due a refresh. // Expeditions age faster — their totals are the interesting number and move by // the hour; a quiet call is asked about four times a day at most. func (s *Store) StaleEntries(expTTL, otherTTL time.Duration) []string { s.mu.RLock() defer s.mu.RUnlock() now := time.Now() var out []string for call, e := range s.entries { // A starred pattern is a family, not a station — ClubLog has no log // for "VK9*". The base (star stripped) is what gets asked about. ttl := otherTTL if e.IsExpedition { ttl = expTTL } if now.Sub(e.ClubLogUpdatedAt) >= ttl { out = append(out, call) } } return out }