feat(watchlist): the ClubLog expedition enrichment — phase 2 lands
clublog.org/watch.php, per entry: the DXpedition flag, OQRS, LiveStream, the log's QSO total and the last-24h rate — the fields the schema has carried since phase 1. Refreshed on DXHunter's own cadence (hourly for expeditions, six-hourly for the rest), two at a time with a breath between requests: the application API key is shared by every install, so a hundred-entry list must read as a trickle at ClubLog's end. Nothing to configure — OpsLog's own key, already used for cty and Most Wanted, serves. A starred pattern is asked about by its base (ClubLog has no log for VK9*). The card gains the 24h rate beside the QSO total and the Live link to the expedition's stream.
This commit is contained in:
@@ -1398,6 +1398,7 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
// POTA: background poller of api.pota.app so cluster spots can be tagged
|
// POTA: background poller of api.pota.app so cluster spots can be tagged
|
||||||
// when the DX station is currently activating a park. Best-effort.
|
// when the DX station is currently activating a park. Best-effort.
|
||||||
a.pota = pota.New(func(format string, args ...any) { applog.Printf(format, args...) })
|
a.pota = pota.New(func(format string, args ...any) { applog.Printf(format, args...) })
|
||||||
|
a.startWatchlistClubLog()
|
||||||
a.watchPattern.Store(strings.ToUpper(strings.TrimSpace(a.settingOr(keyWatchlistContestPattern, ""))))
|
a.watchPattern.Store(strings.ToUpper(strings.TrimSpace(a.settingOr(keyWatchlistContestPattern, ""))))
|
||||||
go a.pota.Run(a.ctx)
|
go a.pota.Run(a.ctx)
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ package main
|
|||||||
// worked-today answer contest entries are judged by).
|
// worked-today answer contest entries are judged by).
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -234,3 +236,58 @@ func (a *App) watchSpot(dxCall, band, mode, country, comment string, freqHz int6
|
|||||||
"visual": true,
|
"visual": true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// startWatchlistClubLog begins the expedition enrichment: every entry's
|
||||||
|
// ClubLog block (expedition flag, OQRS, LiveStream, QSO totals and 24 h rate)
|
||||||
|
// is refreshed on a rolling schedule — hourly for expeditions, six-hourly for
|
||||||
|
// the rest, DXHunter's own cadence. OpsLog's application API key is used, so
|
||||||
|
// there is nothing to configure.
|
||||||
|
//
|
||||||
|
// Two at a time with a breath between requests: the key is shared by every
|
||||||
|
// OpsLog install, and a hundred-entry watchlist must read as a trickle at
|
||||||
|
// ClubLog's end, not a burst.
|
||||||
|
func (a *App) startWatchlistClubLog() {
|
||||||
|
if a.watchlist == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
client := &http.Client{Timeout: 30 * time.Second}
|
||||||
|
// Let the app finish starting before the first network pass.
|
||||||
|
time.Sleep(15 * time.Second)
|
||||||
|
for {
|
||||||
|
stale := a.watchlist.StaleEntries(1*time.Hour, 6*time.Hour)
|
||||||
|
changed := false
|
||||||
|
for _, call := range stale {
|
||||||
|
// The pattern's BASE is what ClubLog knows: VK9* has no log.
|
||||||
|
base := strings.TrimSuffix(call, "*")
|
||||||
|
if base == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
d, err := watchlist.FetchWatch(ctx, client, clublogAppAPIKey, base)
|
||||||
|
cancel()
|
||||||
|
if err != nil {
|
||||||
|
applog.Printf("watchlist clublog: %s: %v", base, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if a.watchlist.ApplyClubLog(call, d) {
|
||||||
|
changed = true
|
||||||
|
if d.IsExpedition {
|
||||||
|
applog.Printf("watchlist clublog: %s is a DXpedition (%d QSOs, %d/24h, OQRS=%v)",
|
||||||
|
base, func() int {
|
||||||
|
if d.ClubLogInfo != nil {
|
||||||
|
return d.ClubLogInfo.TotalQSOs
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}(), d.QSOs24h(), d.HasOQRS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
time.Sleep(400 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if changed && a.ctx != nil {
|
||||||
|
wruntime.EventsEmit(a.ctx, "watchlist:changed")
|
||||||
|
}
|
||||||
|
time.Sleep(10 * time.Minute)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { useI18n } from '@/lib/i18n';
|
|||||||
import {
|
import {
|
||||||
WatchlistEntries, WatchlistAdd, WatchlistRemove, WatchlistSetNotify,
|
WatchlistEntries, WatchlistAdd, WatchlistRemove, WatchlistSetNotify,
|
||||||
WatchlistSetContest, WatchlistWorkedSlots,
|
WatchlistSetContest, WatchlistWorkedSlots,
|
||||||
GetWatchlistContestPattern, SetWatchlistContestPattern,
|
GetWatchlistContestPattern, SetWatchlistContestPattern, OpenExternalURL,
|
||||||
} from '../../wailsjs/go/main/App';
|
} from '../../wailsjs/go/main/App';
|
||||||
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||||
import type { ClusterSpot, SpotStatusEntry } from '@/components/ClusterGrid';
|
import type { ClusterSpot, SpotStatusEntry } from '@/components/ClusterGrid';
|
||||||
@@ -341,8 +341,12 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{e.isExpedition && chip('var(--chart-5)', t('wl.expedition'))}
|
{e.isExpedition && chip('var(--chart-5)', t('wl.expedition'))}
|
||||||
{e.clubLogTotalQSOs > 0 && <span className="text-[11px] text-muted-foreground">{e.clubLogTotalQSOs.toLocaleString()} QSOs</span>}
|
{e.clubLogTotalQSOs > 0 && <span className="text-[11px] text-muted-foreground">{e.clubLogTotalQSOs.toLocaleString()} QSOs{e.clubLogQSOs24h > 0 ? ` · ${e.clubLogQSOs24h}/24h` : ''}</span>}
|
||||||
{e.clubLogHasOQRS && chip('var(--success)', 'OQRS')}
|
{e.clubLogHasOQRS && chip('var(--success)', 'OQRS')}
|
||||||
|
{e.clubLogLiveStream && (
|
||||||
|
<a href="#" onClick={(ev) => { ev.preventDefault(); void OpenExternalURL(`https://clublog.org/livestream/${e.callsign.replace('*', '')}`); }}
|
||||||
|
className="px-1.5 py-0.5 rounded text-[10px] font-bold border text-info border-info/40 bg-info/10 hover:bg-info/20">Live</a>
|
||||||
|
)}
|
||||||
{list.length > 0 && (needed > 0
|
{list.length > 0 && (needed > 0
|
||||||
? chip('var(--warning)', e.isContest ? t('wl.nToday', { n: needed }) : t('wl.nNeeded', { n: needed }))
|
? chip('var(--warning)', e.isContest ? t('wl.nToday', { n: needed }) : t('wl.nNeeded', { n: needed }))
|
||||||
: chip('var(--success)', e.isContest ? t('wl.workedToday') : t('wl.allWorked')))}
|
: chip('var(--success)', e.isContest ? t('wl.workedToday') : t('wl.allWorked')))}
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user